optexity-browser-use 0.9.5__py3-none-any.whl

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.
Files changed (147) hide show
  1. browser_use/__init__.py +157 -0
  2. browser_use/actor/__init__.py +11 -0
  3. browser_use/actor/element.py +1175 -0
  4. browser_use/actor/mouse.py +134 -0
  5. browser_use/actor/page.py +561 -0
  6. browser_use/actor/playground/flights.py +41 -0
  7. browser_use/actor/playground/mixed_automation.py +54 -0
  8. browser_use/actor/playground/playground.py +236 -0
  9. browser_use/actor/utils.py +176 -0
  10. browser_use/agent/cloud_events.py +282 -0
  11. browser_use/agent/gif.py +424 -0
  12. browser_use/agent/judge.py +170 -0
  13. browser_use/agent/message_manager/service.py +473 -0
  14. browser_use/agent/message_manager/utils.py +52 -0
  15. browser_use/agent/message_manager/views.py +98 -0
  16. browser_use/agent/prompts.py +413 -0
  17. browser_use/agent/service.py +2316 -0
  18. browser_use/agent/system_prompt.md +185 -0
  19. browser_use/agent/system_prompt_flash.md +10 -0
  20. browser_use/agent/system_prompt_no_thinking.md +183 -0
  21. browser_use/agent/views.py +743 -0
  22. browser_use/browser/__init__.py +41 -0
  23. browser_use/browser/cloud/cloud.py +203 -0
  24. browser_use/browser/cloud/views.py +89 -0
  25. browser_use/browser/events.py +578 -0
  26. browser_use/browser/profile.py +1158 -0
  27. browser_use/browser/python_highlights.py +548 -0
  28. browser_use/browser/session.py +3225 -0
  29. browser_use/browser/session_manager.py +399 -0
  30. browser_use/browser/video_recorder.py +162 -0
  31. browser_use/browser/views.py +200 -0
  32. browser_use/browser/watchdog_base.py +260 -0
  33. browser_use/browser/watchdogs/__init__.py +0 -0
  34. browser_use/browser/watchdogs/aboutblank_watchdog.py +253 -0
  35. browser_use/browser/watchdogs/crash_watchdog.py +335 -0
  36. browser_use/browser/watchdogs/default_action_watchdog.py +2729 -0
  37. browser_use/browser/watchdogs/dom_watchdog.py +817 -0
  38. browser_use/browser/watchdogs/downloads_watchdog.py +1277 -0
  39. browser_use/browser/watchdogs/local_browser_watchdog.py +461 -0
  40. browser_use/browser/watchdogs/permissions_watchdog.py +43 -0
  41. browser_use/browser/watchdogs/popups_watchdog.py +143 -0
  42. browser_use/browser/watchdogs/recording_watchdog.py +126 -0
  43. browser_use/browser/watchdogs/screenshot_watchdog.py +62 -0
  44. browser_use/browser/watchdogs/security_watchdog.py +280 -0
  45. browser_use/browser/watchdogs/storage_state_watchdog.py +335 -0
  46. browser_use/cli.py +2359 -0
  47. browser_use/code_use/__init__.py +16 -0
  48. browser_use/code_use/formatting.py +192 -0
  49. browser_use/code_use/namespace.py +665 -0
  50. browser_use/code_use/notebook_export.py +276 -0
  51. browser_use/code_use/service.py +1340 -0
  52. browser_use/code_use/system_prompt.md +574 -0
  53. browser_use/code_use/utils.py +150 -0
  54. browser_use/code_use/views.py +171 -0
  55. browser_use/config.py +505 -0
  56. browser_use/controller/__init__.py +3 -0
  57. browser_use/dom/enhanced_snapshot.py +161 -0
  58. browser_use/dom/markdown_extractor.py +169 -0
  59. browser_use/dom/playground/extraction.py +312 -0
  60. browser_use/dom/playground/multi_act.py +32 -0
  61. browser_use/dom/serializer/clickable_elements.py +200 -0
  62. browser_use/dom/serializer/code_use_serializer.py +287 -0
  63. browser_use/dom/serializer/eval_serializer.py +478 -0
  64. browser_use/dom/serializer/html_serializer.py +212 -0
  65. browser_use/dom/serializer/paint_order.py +197 -0
  66. browser_use/dom/serializer/serializer.py +1170 -0
  67. browser_use/dom/service.py +825 -0
  68. browser_use/dom/utils.py +129 -0
  69. browser_use/dom/views.py +906 -0
  70. browser_use/exceptions.py +5 -0
  71. browser_use/filesystem/__init__.py +0 -0
  72. browser_use/filesystem/file_system.py +619 -0
  73. browser_use/init_cmd.py +376 -0
  74. browser_use/integrations/gmail/__init__.py +24 -0
  75. browser_use/integrations/gmail/actions.py +115 -0
  76. browser_use/integrations/gmail/service.py +225 -0
  77. browser_use/llm/__init__.py +155 -0
  78. browser_use/llm/anthropic/chat.py +242 -0
  79. browser_use/llm/anthropic/serializer.py +312 -0
  80. browser_use/llm/aws/__init__.py +36 -0
  81. browser_use/llm/aws/chat_anthropic.py +242 -0
  82. browser_use/llm/aws/chat_bedrock.py +289 -0
  83. browser_use/llm/aws/serializer.py +257 -0
  84. browser_use/llm/azure/chat.py +91 -0
  85. browser_use/llm/base.py +57 -0
  86. browser_use/llm/browser_use/__init__.py +3 -0
  87. browser_use/llm/browser_use/chat.py +201 -0
  88. browser_use/llm/cerebras/chat.py +193 -0
  89. browser_use/llm/cerebras/serializer.py +109 -0
  90. browser_use/llm/deepseek/chat.py +212 -0
  91. browser_use/llm/deepseek/serializer.py +109 -0
  92. browser_use/llm/exceptions.py +29 -0
  93. browser_use/llm/google/__init__.py +3 -0
  94. browser_use/llm/google/chat.py +542 -0
  95. browser_use/llm/google/serializer.py +120 -0
  96. browser_use/llm/groq/chat.py +229 -0
  97. browser_use/llm/groq/parser.py +158 -0
  98. browser_use/llm/groq/serializer.py +159 -0
  99. browser_use/llm/messages.py +238 -0
  100. browser_use/llm/models.py +271 -0
  101. browser_use/llm/oci_raw/__init__.py +10 -0
  102. browser_use/llm/oci_raw/chat.py +443 -0
  103. browser_use/llm/oci_raw/serializer.py +229 -0
  104. browser_use/llm/ollama/chat.py +97 -0
  105. browser_use/llm/ollama/serializer.py +143 -0
  106. browser_use/llm/openai/chat.py +264 -0
  107. browser_use/llm/openai/like.py +15 -0
  108. browser_use/llm/openai/serializer.py +165 -0
  109. browser_use/llm/openrouter/chat.py +211 -0
  110. browser_use/llm/openrouter/serializer.py +26 -0
  111. browser_use/llm/schema.py +176 -0
  112. browser_use/llm/views.py +48 -0
  113. browser_use/logging_config.py +330 -0
  114. browser_use/mcp/__init__.py +18 -0
  115. browser_use/mcp/__main__.py +12 -0
  116. browser_use/mcp/client.py +544 -0
  117. browser_use/mcp/controller.py +264 -0
  118. browser_use/mcp/server.py +1114 -0
  119. browser_use/observability.py +204 -0
  120. browser_use/py.typed +0 -0
  121. browser_use/sandbox/__init__.py +41 -0
  122. browser_use/sandbox/sandbox.py +637 -0
  123. browser_use/sandbox/views.py +132 -0
  124. browser_use/screenshots/__init__.py +1 -0
  125. browser_use/screenshots/service.py +52 -0
  126. browser_use/sync/__init__.py +6 -0
  127. browser_use/sync/auth.py +357 -0
  128. browser_use/sync/service.py +161 -0
  129. browser_use/telemetry/__init__.py +51 -0
  130. browser_use/telemetry/service.py +112 -0
  131. browser_use/telemetry/views.py +101 -0
  132. browser_use/tokens/__init__.py +0 -0
  133. browser_use/tokens/custom_pricing.py +24 -0
  134. browser_use/tokens/mappings.py +4 -0
  135. browser_use/tokens/service.py +580 -0
  136. browser_use/tokens/views.py +108 -0
  137. browser_use/tools/registry/service.py +572 -0
  138. browser_use/tools/registry/views.py +174 -0
  139. browser_use/tools/service.py +1675 -0
  140. browser_use/tools/utils.py +82 -0
  141. browser_use/tools/views.py +100 -0
  142. browser_use/utils.py +670 -0
  143. optexity_browser_use-0.9.5.dist-info/METADATA +344 -0
  144. optexity_browser_use-0.9.5.dist-info/RECORD +147 -0
  145. optexity_browser_use-0.9.5.dist-info/WHEEL +4 -0
  146. optexity_browser_use-0.9.5.dist-info/entry_points.txt +3 -0
  147. optexity_browser_use-0.9.5.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,399 @@
1
+ """Event-driven CDP session management.
2
+
3
+ Manages CDP sessions by listening to Target.attachedToTarget and Target.detachedFromTarget
4
+ events, ensuring the session pool always reflects the current browser state.
5
+ """
6
+
7
+ import asyncio
8
+ from typing import TYPE_CHECKING
9
+
10
+ from cdp_use.cdp.target import AttachedToTargetEvent, DetachedFromTargetEvent, SessionID, TargetID
11
+
12
+ if TYPE_CHECKING:
13
+ from browser_use.browser.session import BrowserSession, CDPSession
14
+
15
+
16
+ class SessionManager:
17
+ """Event-driven CDP session manager.
18
+
19
+ Automatically synchronizes the CDP session pool with browser state via CDP events.
20
+
21
+ Key features:
22
+ - Sessions added/removed automatically via Target attach/detach events
23
+ - Multiple sessions can attach to the same target
24
+ - Targets only removed when ALL sessions detach
25
+ - No stale sessions - pool always reflects browser reality
26
+ """
27
+
28
+ def __init__(self, browser_session: 'BrowserSession'):
29
+ self.browser_session = browser_session
30
+ self.logger = browser_session.logger
31
+
32
+ # Target -> set of sessions attached to it
33
+ self._target_sessions: dict[TargetID, set[SessionID]] = {}
34
+
35
+ # Session -> target mapping for reverse lookup
36
+ self._session_to_target: dict[SessionID, TargetID] = {}
37
+
38
+ # Target -> type cache (page, iframe, worker, etc.) - types are immutable
39
+ self._target_types: dict[TargetID, str] = {}
40
+
41
+ # Lock for thread-safe access
42
+ self._lock = asyncio.Lock()
43
+
44
+ # Lock for recovery to prevent concurrent recovery attempts
45
+ self._recovery_lock = asyncio.Lock()
46
+
47
+ async def start_monitoring(self) -> None:
48
+ """Start monitoring Target attach/detach events.
49
+
50
+ Registers CDP event handlers to keep the session pool synchronized with browser state.
51
+ """
52
+ if not self.browser_session._cdp_client_root:
53
+ raise RuntimeError('CDP client not initialized')
54
+
55
+ # Capture cdp_client_root in closure to avoid type errors
56
+ cdp_client = self.browser_session._cdp_client_root
57
+
58
+ # Register synchronous event handlers (CDP requirement)
59
+ def on_attached(event: AttachedToTargetEvent, session_id: SessionID | None = None):
60
+ event_session_id = event['sessionId']
61
+ target_type = event['targetInfo'].get('type', 'unknown')
62
+
63
+ # Enable auto-attach for this session's children
64
+ async def _enable_auto_attach():
65
+ try:
66
+ await cdp_client.send.Target.setAutoAttach(
67
+ params={'autoAttach': True, 'waitForDebuggerOnStart': False, 'flatten': True}, session_id=event_session_id
68
+ )
69
+ self.logger.debug(f'[SessionManager] Auto-attach enabled for {target_type} session {event_session_id[:8]}...')
70
+ except Exception as e:
71
+ error_str = str(e)
72
+ # Expected for short-lived targets (workers, temp iframes) that detach before task executes
73
+ if '-32001' in error_str or 'Session with given id not found' in error_str:
74
+ self.logger.debug(
75
+ f'[SessionManager] Auto-attach skipped for {target_type} session {event_session_id[:8]}... '
76
+ f'(already detached - normal for short-lived targets)'
77
+ )
78
+ else:
79
+ self.logger.debug(f'[SessionManager] Auto-attach failed for {target_type}: {e}')
80
+
81
+ # Schedule auto-attach and pool management
82
+ asyncio.create_task(_enable_auto_attach())
83
+ asyncio.create_task(self._handle_target_attached(event))
84
+
85
+ def on_detached(event: DetachedFromTargetEvent, session_id: SessionID | None = None):
86
+ asyncio.create_task(self._handle_target_detached(event))
87
+
88
+ self.browser_session._cdp_client_root.register.Target.attachedToTarget(on_attached)
89
+ self.browser_session._cdp_client_root.register.Target.detachedFromTarget(on_detached)
90
+
91
+ self.logger.debug('[SessionManager] Event monitoring started')
92
+
93
+ async def get_session_for_target(self, target_id: TargetID) -> 'CDPSession | None':
94
+ """Get the current valid session for a target.
95
+
96
+ Args:
97
+ target_id: Target ID to get session for
98
+
99
+ Returns:
100
+ CDPSession if exists, None if target has detached
101
+ """
102
+ async with self._lock:
103
+ return self.browser_session._cdp_session_pool.get(target_id)
104
+
105
+ async def validate_session(self, target_id: TargetID) -> bool:
106
+ """Check if a target still has active sessions.
107
+
108
+ Args:
109
+ target_id: Target ID to validate
110
+
111
+ Returns:
112
+ True if target has active sessions, False if it should be removed
113
+ """
114
+ async with self._lock:
115
+ if target_id not in self._target_sessions:
116
+ return False
117
+
118
+ return len(self._target_sessions[target_id]) > 0
119
+
120
+ async def clear(self) -> None:
121
+ """Clear all session tracking for cleanup."""
122
+ async with self._lock:
123
+ self._target_sessions.clear()
124
+ self._session_to_target.clear()
125
+ self._target_types.clear()
126
+
127
+ self.logger.info('[SessionManager] Cleared all session tracking')
128
+
129
+ async def is_target_valid(self, target_id: TargetID) -> bool:
130
+ """Check if a target is still valid and has active sessions.
131
+
132
+ Args:
133
+ target_id: Target ID to validate
134
+
135
+ Returns:
136
+ True if target is valid and has active sessions, False otherwise
137
+ """
138
+ async with self._lock:
139
+ if target_id not in self._target_sessions:
140
+ return False
141
+ return len(self._target_sessions[target_id]) > 0
142
+
143
+ async def _handle_target_attached(self, event: AttachedToTargetEvent) -> None:
144
+ """Handle Target.attachedToTarget event.
145
+
146
+ Called automatically by Chrome when a new target/session is created.
147
+ This is the ONLY place where sessions are added to the pool.
148
+ """
149
+ target_id = event['targetInfo']['targetId']
150
+ session_id = event['sessionId']
151
+ target_type = event['targetInfo']['type']
152
+ waiting_for_debugger = event.get('waitingForDebugger', False)
153
+
154
+ self.logger.debug(
155
+ f'[SessionManager] Target attached: {target_id[:8]}... (session={session_id[:8]}..., '
156
+ f'type={target_type}, waitingForDebugger={waiting_for_debugger})'
157
+ )
158
+
159
+ async with self._lock:
160
+ # Track this session for the target
161
+ if target_id not in self._target_sessions:
162
+ self._target_sessions[target_id] = set()
163
+
164
+ self._target_sessions[target_id].add(session_id)
165
+ self._session_to_target[session_id] = target_id
166
+
167
+ # Cache target type (immutable, set once)
168
+ if target_id not in self._target_types:
169
+ self._target_types[target_id] = target_type
170
+
171
+ # Create CDPSession wrapper and add to pool
172
+ if target_id not in self.browser_session._cdp_session_pool:
173
+ from browser_use.browser.session import CDPSession
174
+
175
+ assert self.browser_session._cdp_client_root is not None, 'Root CDP client required'
176
+
177
+ cdp_session = CDPSession(
178
+ cdp_client=self.browser_session._cdp_client_root,
179
+ target_id=target_id,
180
+ session_id=session_id,
181
+ title=event['targetInfo'].get('title', 'Unknown title'),
182
+ url=event['targetInfo'].get('url', 'about:blank'),
183
+ )
184
+
185
+ self.browser_session._cdp_session_pool[target_id] = cdp_session
186
+
187
+ self.logger.debug(
188
+ f'[SessionManager] Created session for target {target_id[:8]}... '
189
+ f'(pool size: {len(self.browser_session._cdp_session_pool)})'
190
+ )
191
+ else:
192
+ # Update existing session with new session_id
193
+ existing = self.browser_session._cdp_session_pool[target_id]
194
+ existing.session_id = session_id
195
+ existing.title = event['targetInfo'].get('title', existing.title)
196
+ existing.url = event['targetInfo'].get('url', existing.url)
197
+
198
+ # Resume execution if waiting for debugger
199
+ if waiting_for_debugger:
200
+ try:
201
+ assert self.browser_session._cdp_client_root is not None
202
+ await self.browser_session._cdp_client_root.send.Runtime.runIfWaitingForDebugger(session_id=session_id)
203
+ self.logger.debug(f'[SessionManager] Resumed execution for session {session_id[:8]}...')
204
+ except Exception as e:
205
+ self.logger.warning(f'[SessionManager] Failed to resume execution: {e}')
206
+
207
+ async def _handle_target_detached(self, event: DetachedFromTargetEvent) -> None:
208
+ """Handle Target.detachedFromTarget event.
209
+
210
+ Called automatically by Chrome when a target/session is destroyed.
211
+ This is the ONLY place where sessions are removed from the pool.
212
+ """
213
+ session_id = event['sessionId']
214
+ target_id = event.get('targetId') # May be empty
215
+
216
+ # If targetId not in event, look it up via session mapping
217
+ if not target_id:
218
+ async with self._lock:
219
+ target_id = self._session_to_target.get(session_id)
220
+
221
+ if not target_id:
222
+ self.logger.warning(f'[SessionManager] Session detached but target unknown (session={session_id[:8]}...)')
223
+ return
224
+
225
+ agent_focus_lost = False
226
+ target_fully_removed = False
227
+ target_type = None
228
+
229
+ async with self._lock:
230
+ # Remove this session from target's session set
231
+ if target_id in self._target_sessions:
232
+ self._target_sessions[target_id].discard(session_id)
233
+
234
+ remaining_sessions = len(self._target_sessions[target_id])
235
+
236
+ self.logger.debug(
237
+ f'[SessionManager] Session detached: target={target_id[:8]}... '
238
+ f'session={session_id[:8]}... (remaining={remaining_sessions})'
239
+ )
240
+
241
+ # Only remove target when NO sessions remain
242
+ if remaining_sessions == 0:
243
+ self.logger.debug(f'[SessionManager] No sessions remain for target {target_id[:8]}..., removing from pool')
244
+
245
+ target_fully_removed = True
246
+
247
+ # Check if agent_focus points to this target
248
+ agent_focus_lost = (
249
+ self.browser_session.agent_focus and self.browser_session.agent_focus.target_id == target_id
250
+ )
251
+
252
+ # Remove from pool
253
+ if target_id in self.browser_session._cdp_session_pool:
254
+ self.browser_session._cdp_session_pool.pop(target_id)
255
+ self.logger.debug(
256
+ f'[SessionManager] Removed target {target_id[:8]}... from pool '
257
+ f'(pool size: {len(self.browser_session._cdp_session_pool)})'
258
+ )
259
+
260
+ # Clean up tracking
261
+ del self._target_sessions[target_id]
262
+ else:
263
+ # Target not tracked - already removed or never attached
264
+ self.logger.debug(
265
+ f'[SessionManager] Session detached from untracked target: target={target_id[:8]}... '
266
+ f'session={session_id[:8]}... (target was already removed or attach event was missed)'
267
+ )
268
+
269
+ # Get target type before cleaning up cache (needed for TabClosedEvent dispatch)
270
+ target_type = self._target_types.get(target_id)
271
+
272
+ # Clean up target type cache if target fully removed
273
+ if target_id not in self._target_sessions and target_id in self._target_types:
274
+ del self._target_types[target_id]
275
+
276
+ # Remove from reverse mapping
277
+ if session_id in self._session_to_target:
278
+ del self._session_to_target[session_id]
279
+
280
+ # Dispatch TabClosedEvent only for page/tab targets that are fully removed (not iframes/workers or partial detaches)
281
+ if target_fully_removed:
282
+ if target_type in ('page', 'tab'):
283
+ from browser_use.browser.events import TabClosedEvent
284
+
285
+ self.browser_session.event_bus.dispatch(TabClosedEvent(target_id=target_id))
286
+ self.logger.debug(f'[SessionManager] Dispatched TabClosedEvent for page target {target_id[:8]}...')
287
+ elif target_type:
288
+ self.logger.debug(
289
+ f'[SessionManager] Target {target_id[:8]}... fully removed (type={target_type}) - not dispatching TabClosedEvent'
290
+ )
291
+
292
+ # Auto-recover agent_focus outside the lock to avoid blocking other operations
293
+ if agent_focus_lost:
294
+ await self._recover_agent_focus(target_id)
295
+
296
+ async def _recover_agent_focus(self, crashed_target_id: TargetID) -> None:
297
+ """Auto-recover agent_focus when the focused target crashes/detaches.
298
+
299
+ Uses recovery lock to prevent concurrent recovery attempts from creating multiple emergency tabs.
300
+
301
+ Args:
302
+ crashed_target_id: The target ID that was lost
303
+ """
304
+ # Prevent concurrent recovery attempts
305
+ async with self._recovery_lock:
306
+ # Check if another recovery already fixed agent_focus
307
+ if self.browser_session.agent_focus and self.browser_session.agent_focus.target_id != crashed_target_id:
308
+ self.logger.debug(
309
+ f'[SessionManager] Agent focus already recovered by concurrent operation '
310
+ f'(now: {self.browser_session.agent_focus.target_id[:8]}...), skipping recovery'
311
+ )
312
+ return
313
+
314
+ self.logger.warning(
315
+ f'[SessionManager] Agent focus target {crashed_target_id[:8]}... detached! '
316
+ f'Auto-recovering by switching to another target...'
317
+ )
318
+
319
+ try:
320
+ # Try to find another valid page target
321
+ all_pages = await self.browser_session._cdp_get_all_pages()
322
+
323
+ new_target_id = None
324
+ is_existing_tab = False
325
+
326
+ if all_pages:
327
+ # Switch to most recent page that's not the crashed one
328
+ new_target_id = all_pages[-1]['targetId']
329
+ is_existing_tab = True
330
+ self.logger.info(f'[SessionManager] Switching agent_focus to existing tab {new_target_id[:8]}...')
331
+ else:
332
+ # No pages exist - create a new one
333
+ self.logger.warning('[SessionManager] No tabs remain! Creating new tab for agent...')
334
+ new_target_id = await self.browser_session._cdp_create_new_page('about:blank')
335
+ self.logger.info(f'[SessionManager] Created new tab {new_target_id[:8]}... for agent')
336
+
337
+ # Dispatch TabCreatedEvent so watchdogs can initialize
338
+ from browser_use.browser.events import TabCreatedEvent
339
+
340
+ self.browser_session.event_bus.dispatch(TabCreatedEvent(url='about:blank', target_id=new_target_id))
341
+
342
+ # Wait for attach event to create session, then update agent_focus
343
+ new_session = None
344
+ for attempt in range(20): # Wait up to 2 seconds
345
+ await asyncio.sleep(0.1)
346
+ new_session = await self.get_session_for_target(new_target_id)
347
+ if new_session:
348
+ break
349
+
350
+ if new_session:
351
+ self.browser_session.agent_focus = new_session
352
+ self.logger.info(f'[SessionManager] ✅ Agent focus recovered: {new_target_id[:8]}...')
353
+
354
+ # Visually activate the tab in browser (only for existing tabs)
355
+ if is_existing_tab:
356
+ try:
357
+ assert self.browser_session._cdp_client_root is not None
358
+ await self.browser_session._cdp_client_root.send.Target.activateTarget(params={'targetId': new_target_id})
359
+ self.logger.debug(f'[SessionManager] Activated tab {new_target_id[:8]}... in browser UI')
360
+ except Exception as e:
361
+ self.logger.debug(f'[SessionManager] Failed to activate tab visually: {e}')
362
+
363
+ # Dispatch focus changed event
364
+ from browser_use.browser.events import AgentFocusChangedEvent
365
+
366
+ self.browser_session.event_bus.dispatch(AgentFocusChangedEvent(target_id=new_target_id, url=new_session.url))
367
+ return
368
+
369
+ # Recovery failed - create emergency fallback tab
370
+ self.logger.error(
371
+ f'[SessionManager] ❌ Failed to get session for {new_target_id[:8]}... after 2s, creating emergency fallback tab'
372
+ )
373
+
374
+ fallback_target_id = await self.browser_session._cdp_create_new_page('about:blank')
375
+ self.logger.warning(f'[SessionManager] Created emergency fallback tab {fallback_target_id[:8]}...')
376
+
377
+ # Try one more time with fallback
378
+ for _ in range(20):
379
+ await asyncio.sleep(0.1)
380
+ fallback_session = await self.get_session_for_target(fallback_target_id)
381
+ if fallback_session:
382
+ self.browser_session.agent_focus = fallback_session
383
+ self.logger.warning(f'[SessionManager] ⚠️ Agent focus set to emergency fallback: {fallback_target_id[:8]}...')
384
+
385
+ from browser_use.browser.events import AgentFocusChangedEvent, TabCreatedEvent
386
+
387
+ self.browser_session.event_bus.dispatch(TabCreatedEvent(url='about:blank', target_id=fallback_target_id))
388
+ self.browser_session.event_bus.dispatch(
389
+ AgentFocusChangedEvent(target_id=fallback_target_id, url='about:blank')
390
+ )
391
+ return
392
+
393
+ # Complete failure - this should never happen
394
+ self.logger.critical(
395
+ '[SessionManager] 🚨 CRITICAL: Failed to recover agent_focus even with fallback! Agent may be in broken state.'
396
+ )
397
+
398
+ except Exception as e:
399
+ self.logger.error(f'[SessionManager] ❌ Error during agent_focus recovery: {type(e).__name__}: {e}')
@@ -0,0 +1,162 @@
1
+ """Video Recording Service for Browser Use Sessions."""
2
+
3
+ import base64
4
+ import logging
5
+ import math
6
+ import subprocess
7
+ from pathlib import Path
8
+ from typing import Optional
9
+
10
+ from browser_use.browser.profile import ViewportSize
11
+
12
+ try:
13
+ import imageio.v2 as iio # type: ignore[import-not-found]
14
+ import imageio_ffmpeg # type: ignore[import-not-found]
15
+ import numpy as np # type: ignore[import-not-found]
16
+ from imageio.core.format import Format # type: ignore[import-not-found]
17
+
18
+ IMAGEIO_AVAILABLE = True
19
+ except ImportError:
20
+ IMAGEIO_AVAILABLE = False
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+
25
+ def _get_padded_size(size: ViewportSize, macro_block_size: int = 16) -> ViewportSize:
26
+ """Calculates the dimensions padded to the nearest multiple of macro_block_size."""
27
+ width = int(math.ceil(size['width'] / macro_block_size)) * macro_block_size
28
+ height = int(math.ceil(size['height'] / macro_block_size)) * macro_block_size
29
+ return ViewportSize(width=width, height=height)
30
+
31
+
32
+ class VideoRecorderService:
33
+ """
34
+ Handles the video encoding process for a browser session using imageio.
35
+
36
+ This service captures individual frames from the CDP screencast, decodes them,
37
+ and appends them to a video file using a pip-installable ffmpeg backend.
38
+ It automatically resizes frames to match the target video dimensions.
39
+ """
40
+
41
+ def __init__(self, output_path: Path, size: ViewportSize, framerate: int):
42
+ """
43
+ Initializes the video recorder.
44
+
45
+ Args:
46
+ output_path: The full path where the video will be saved.
47
+ size: A ViewportSize object specifying the width and height of the video.
48
+ framerate: The desired framerate for the output video.
49
+ """
50
+ self.output_path = output_path
51
+ self.size = size
52
+ self.framerate = framerate
53
+ self._writer: Optional['Format.Writer'] = None
54
+ self._is_active = False
55
+ self.padded_size = _get_padded_size(self.size)
56
+
57
+ def start(self) -> None:
58
+ """
59
+ Prepares and starts the video writer.
60
+
61
+ If the required optional dependencies are not installed, this method will
62
+ log an error and do nothing.
63
+ """
64
+ if not IMAGEIO_AVAILABLE:
65
+ logger.error(
66
+ 'MP4 recording requires optional dependencies. Please install them with: pip install "browser-use[video]"'
67
+ )
68
+ return
69
+
70
+ try:
71
+ self.output_path.parent.mkdir(parents=True, exist_ok=True)
72
+ # The macro_block_size is set to None because we handle padding ourselves
73
+ self._writer = iio.get_writer(
74
+ str(self.output_path),
75
+ fps=self.framerate,
76
+ codec='libx264',
77
+ quality=8, # A good balance of quality and file size (1-10 scale)
78
+ pixelformat='yuv420p', # Ensures compatibility with most players
79
+ macro_block_size=None,
80
+ )
81
+ self._is_active = True
82
+ logger.debug(f'Video recorder started. Output will be saved to {self.output_path}')
83
+ except Exception as e:
84
+ logger.error(f'Failed to initialize video writer: {e}')
85
+ self._is_active = False
86
+
87
+ def add_frame(self, frame_data_b64: str) -> None:
88
+ """
89
+ Decodes a base64-encoded PNG frame, resizes it, pads it to be codec-compatible,
90
+ and appends it to the video.
91
+
92
+ Args:
93
+ frame_data_b64: A base64-encoded string of the PNG frame data.
94
+ """
95
+ if not self._is_active or not self._writer:
96
+ return
97
+
98
+ try:
99
+ frame_bytes = base64.b64decode(frame_data_b64)
100
+
101
+ # Build a filter chain for ffmpeg:
102
+ # 1. scale: Resizes the frame to the user-specified dimensions.
103
+ # 2. pad: Adds black bars to meet codec's macro-block requirements,
104
+ # centering the original content.
105
+ vf_chain = (
106
+ f'scale={self.size["width"]}:{self.size["height"]},'
107
+ f'pad={self.padded_size["width"]}:{self.padded_size["height"]}:(ow-iw)/2:(oh-ih)/2:color=black'
108
+ )
109
+
110
+ output_pix_fmt = 'rgb24'
111
+ command = [
112
+ imageio_ffmpeg.get_ffmpeg_exe(),
113
+ '-f',
114
+ 'image2pipe', # Input format from a pipe
115
+ '-c:v',
116
+ 'png', # Specify input codec is PNG
117
+ '-i',
118
+ '-', # Input from stdin
119
+ '-vf',
120
+ vf_chain, # Video filter for resizing and padding
121
+ '-f',
122
+ 'rawvideo', # Output format is raw video
123
+ '-pix_fmt',
124
+ output_pix_fmt, # Output pixel format
125
+ '-', # Output to stdout
126
+ ]
127
+
128
+ # Execute ffmpeg as a subprocess
129
+ proc = subprocess.Popen(command, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
130
+ out, err = proc.communicate(input=frame_bytes)
131
+
132
+ if proc.returncode != 0:
133
+ err_msg = err.decode(errors='ignore').strip()
134
+ if 'deprecated pixel format used' not in err_msg.lower():
135
+ raise OSError(f'ffmpeg error during resizing/padding: {err_msg}')
136
+ else:
137
+ logger.debug(f'ffmpeg warning during resizing/padding: {err_msg}')
138
+
139
+ # Convert the raw output bytes to a numpy array with the padded dimensions
140
+ img_array = np.frombuffer(out, dtype=np.uint8).reshape((self.padded_size['height'], self.padded_size['width'], 3))
141
+
142
+ self._writer.append_data(img_array)
143
+ except Exception as e:
144
+ logger.warning(f'Could not process and add video frame: {e}')
145
+
146
+ def stop_and_save(self) -> None:
147
+ """
148
+ Finalizes the video file by closing the writer.
149
+
150
+ This method should be called when the recording session is complete.
151
+ """
152
+ if not self._is_active or not self._writer:
153
+ return
154
+
155
+ try:
156
+ self._writer.close()
157
+ logger.info(f'📹 Video recording saved successfully to: {self.output_path}')
158
+ except Exception as e:
159
+ logger.error(f'Failed to finalize and save video: {e}')
160
+ finally:
161
+ self._is_active = False
162
+ self._writer = None