perf(browser): encode progress frames as JPEG (#4836)

This commit is contained in:
Ryker_Feng 2026-08-15 21:41:21 +08:00 committed by GitHub
parent 432c09f6b0
commit 8be08101b3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 116 additions and 14 deletions

View File

@ -25,6 +25,15 @@ same helper for the embedded gate.
trace id and not a DeerFlow `run_id`. Keep the existing subagent `trace_id` field trace id and not a DeerFlow `run_id`. Keep the existing subagent `trace_id` field
separate: that short id is still only for subagent execution logs/status. separate: that short id is still only for subagent execution logs/status.
### Browser Progress Screenshots (`community/browser_automation/`)
Hidden per-action browser progress frames use JPEG at quality 80 to keep their
storage and transfer cost bounded relative to lossless PNG. The explicit
`browser_screenshot` tool remains PNG because it creates a user-requested
artifact. New automatic capture entry points must reuse the shared progress
encoding definition in `tools.py` so the byte encoding and `.jpg` suffix cannot
drift.
### Embedded Client (`packages/harness/deerflow/client.py`) ### Embedded Client (`packages/harness/deerflow/client.py`)
`DeerFlowClient` provides direct in-process access to all DeerFlow capabilities without HTTP services. All return types align with the Gateway API response schemas, so consumer code works identically in HTTP and embedded modes. `DeerFlowClient` provides direct in-process access to all DeerFlow capabilities without HTTP services. All return types align with the Gateway API response schemas, so consumer code works identically in HTTP and embedded modes.

View File

@ -22,7 +22,7 @@ import threading
import time import time
from collections.abc import Callable, Coroutine from collections.abc import Callable, Coroutine
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any, TypeVar from typing import TYPE_CHECKING, Any, Literal, TypeVar
from urllib.parse import urlparse from urllib.parse import urlparse
if TYPE_CHECKING: if TYPE_CHECKING:
@ -31,6 +31,7 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
T = TypeVar("T") T = TypeVar("T")
ScreenshotType = Literal["png", "jpeg", "webp"]
# Element roles/tags treated as interactive when building a page snapshot. The # Element roles/tags treated as interactive when building a page snapshot. The
# model addresses elements by the ``data-df-ref`` index this snapshot stamps, so # model addresses elements by the ``data-df-ref`` index this snapshot stamps, so
@ -533,9 +534,16 @@ class BrowserSession:
text = await page.inner_text("body") text = await page.inner_text("body")
return text[:max_chars] return text[:max_chars]
async def _screenshot_bytes(self, full_page: bool) -> bytes: async def _screenshot_bytes(
self,
full_page: bool,
image_type: ScreenshotType,
quality: int | None,
) -> bytes:
page = await self._ensure_page() page = await self._ensure_page()
return await page.screenshot(full_page=full_page, type="png") if quality is None:
return await page.screenshot(full_page=full_page, type=image_type)
return await page.screenshot(full_page=full_page, type=image_type, quality=quality)
async def _live_frame(self) -> bytes: async def _live_frame(self) -> bytes:
page = await self._ensure_page() page = await self._ensure_page()
@ -786,9 +794,15 @@ class BrowserSession:
with self._activity(): with self._activity():
return await self._loop.run(self._get_text(max_chars)) return await self._loop.run(self._get_text(max_chars))
async def screenshot_bytes(self, full_page: bool = False) -> bytes: async def screenshot_bytes(
self,
full_page: bool = False,
*,
image_type: ScreenshotType = "png",
quality: int | None = None,
) -> bytes:
with self._activity(): with self._activity():
return await self._loop.run(self._screenshot_bytes(full_page)) return await self._loop.run(self._screenshot_bytes(full_page, image_type, quality))
async def live_frame(self) -> bytes: async def live_frame(self) -> bytes:
with self._activity(): with self._activity():

View File

@ -17,6 +17,7 @@ import asyncio
import contextlib import contextlib
import logging import logging
import re import re
from dataclasses import dataclass
from datetime import UTC, datetime from datetime import UTC, datetime
from pathlib import Path from pathlib import Path
from typing import Annotated from typing import Annotated
@ -32,7 +33,7 @@ from deerflow.config.paths import VIRTUAL_PATH_PREFIX
from deerflow.constants import BROWSER_FRAMES_DIRNAME from deerflow.constants import BROWSER_FRAMES_DIRNAME
from deerflow.tools.types import Runtime from deerflow.tools.types import Runtime
from .session import BrowserSession, BrowserSessionManager, PageSnapshot, get_browser_session_manager from .session import BrowserSession, BrowserSessionManager, PageSnapshot, ScreenshotType, get_browser_session_manager
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -46,6 +47,16 @@ _FRAMES_VIRTUAL_PREFIX = f"{_OUTPUTS_VIRTUAL_PREFIX}/{_BROWSER_FRAMES_DIRNAME}"
_SAFE_FILENAME_RE = re.compile(r"[^A-Za-z0-9._-]+") _SAFE_FILENAME_RE = re.compile(r"[^A-Za-z0-9._-]+")
@dataclass(frozen=True)
class _ScreenshotEncoding:
image_type: ScreenshotType
suffix: str
quality: int | None = None
_PROGRESS_SCREENSHOT_ENCODING = _ScreenshotEncoding(image_type="jpeg", suffix=".jpg", quality=80)
def _get_tool_config(tool_name: str) -> dict: def _get_tool_config(tool_name: str) -> dict:
config = get_app_config().get_tool_config(tool_name) config = get_app_config().get_tool_config(tool_name)
if config is None: if config is None:
@ -167,7 +178,7 @@ def _tool_message(content: str, tool_call_id: str) -> Command:
def _step_screenshot_name(action: str) -> str: def _step_screenshot_name(action: str) -> str:
stamp = datetime.now(UTC).strftime("%Y%m%d-%H%M%S-%f") stamp = datetime.now(UTC).strftime("%Y%m%d-%H%M%S-%f")
safe = _SAFE_FILENAME_RE.sub("_", action).strip("._-") or "step" safe = _SAFE_FILENAME_RE.sub("_", action).strip("._-") or "step"
return f"browser-{safe}-{stamp}.png" return f"browser-{safe}-{stamp}{_PROGRESS_SCREENSHOT_ENCODING.suffix}"
async def _capture_step_screenshot(runtime: Runtime, session: BrowserSession, action: str) -> str | None: async def _capture_step_screenshot(runtime: Runtime, session: BrowserSession, action: str) -> str | None:
@ -181,7 +192,11 @@ async def _capture_step_screenshot(runtime: Runtime, session: BrowserSession, ac
if isinstance(outputs_path, str): if isinstance(outputs_path, str):
return None return None
try: try:
content = await session.screenshot_bytes(full_page=False) content = await session.screenshot_bytes(
full_page=False,
image_type=_PROGRESS_SCREENSHOT_ENCODING.image_type,
quality=_PROGRESS_SCREENSHOT_ENCODING.quality,
)
name = _step_screenshot_name(action) name = _step_screenshot_name(action)
frames_dir = outputs_path / _BROWSER_FRAMES_DIRNAME frames_dir = outputs_path / _BROWSER_FRAMES_DIRNAME
final_name = await asyncio.to_thread(_write_screenshot, frames_dir, name, content) final_name = await asyncio.to_thread(_write_screenshot, frames_dir, name, content)
@ -244,7 +259,11 @@ async def navigate_and_capture(*, thread_id: str | None, url: str, outputs_path:
snapshot = await session.navigate(url) snapshot = await session.navigate(url)
screenshot_path: str | None = None screenshot_path: str | None = None
try: try:
content = await session.screenshot_bytes(full_page=False) content = await session.screenshot_bytes(
full_page=False,
image_type=_PROGRESS_SCREENSHOT_ENCODING.image_type,
quality=_PROGRESS_SCREENSHOT_ENCODING.quality,
)
name = _step_screenshot_name("navigate") name = _step_screenshot_name("navigate")
frames_dir = outputs_path / _BROWSER_FRAMES_DIRNAME frames_dir = outputs_path / _BROWSER_FRAMES_DIRNAME
final_name = await asyncio.to_thread(_write_screenshot, frames_dir, name, content) final_name = await asyncio.to_thread(_write_screenshot, frames_dir, name, content)

View File

@ -90,7 +90,7 @@ class TestBrowserTools:
outputs.mkdir() outputs.mkdir()
session = MagicMock() session = MagicMock()
session.navigate = AsyncMock(return_value=_snapshot()) session.navigate = AsyncMock(return_value=_snapshot())
session.screenshot_bytes = AsyncMock(return_value=b"\x89PNG\r\n\x1a\nshot") session.screenshot_bytes = AsyncMock(return_value=b"\xff\xd8jpeg-shot")
session.schedule_live_frames = MagicMock() session.schedule_live_frames = MagicMock()
ctx, _ = await self._patch_session(session) ctx, _ = await self._patch_session(session)
with ctx, patch.object(tools, "_get_tool_config", return_value={}): with ctx, patch.object(tools, "_get_tool_config", return_value={}):
@ -100,13 +100,13 @@ class TestBrowserTools:
tool_call_id="t1", tool_call_id="t1",
) )
# Screenshot is captured, saved, exposed as an artifact + inline browser_view. # Screenshot is captured, saved, exposed as an artifact + inline browser_view.
session.screenshot_bytes.assert_awaited_once() session.screenshot_bytes.assert_awaited_once_with(full_page=False, image_type="jpeg", quality=80)
session.schedule_live_frames.assert_called_once() session.schedule_live_frames.assert_called_once()
artifact = result.update["artifacts"][0] artifact = result.update["artifacts"][0]
assert artifact.startswith("/mnt/user-data/outputs/.browser-frames/browser-navigate-") assert artifact.startswith("/mnt/user-data/outputs/.browser-frames/browser-navigate-")
assert artifact.endswith(".png") assert artifact.endswith(".jpg")
saved = list((outputs / ".browser-frames").glob("browser-navigate-*.png")) saved = list((outputs / ".browser-frames").glob("browser-navigate-*.jpg"))
assert saved and saved[0].read_bytes() == b"\x89PNG\r\n\x1a\nshot" assert saved and saved[0].read_bytes() == b"\xff\xd8jpeg-shot"
meta = result.update["messages"][0].additional_kwargs["browser_view"] meta = result.update["messages"][0].additional_kwargs["browser_view"]
assert meta["screenshot"] == artifact assert meta["screenshot"] == artifact
assert meta["url"] == "https://example.com/" assert meta["url"] == "https://example.com/"
@ -130,6 +130,29 @@ class TestBrowserTools:
assert "artifacts" not in result.update assert "artifacts" not in result.update
assert result.update["messages"][0].additional_kwargs == {} assert result.update["messages"][0].additional_kwargs == {}
async def test_gateway_navigate_and_capture_uses_jpeg_progress_frame(self, tmp_path):
session = MagicMock()
session.navigate = AsyncMock(return_value=_snapshot())
session.screenshot_bytes = AsyncMock(return_value=b"\xff\xd8gateway-jpeg")
lease = MagicMock()
lease.__enter__.return_value = session
manager = MagicMock()
manager.acquire_session.return_value = lease
with (
patch.object(tools, "_validate_url", return_value=None),
patch.object(tools, "_get_tool_config", return_value={}),
patch.object(tools, "get_browser_session_manager", return_value=manager),
):
result = await tools.navigate_and_capture(
thread_id="thread-1",
url="https://example.com",
outputs_path=tmp_path,
)
session.screenshot_bytes.assert_awaited_once_with(full_page=False, image_type="jpeg", quality=80)
assert result["screenshot"].endswith(".jpg")
async def test_navigate_blocks_private_url(self): async def test_navigate_blocks_private_url(self):
session = MagicMock() session = MagicMock()
session.navigate = AsyncMock() session.navigate = AsyncMock()
@ -212,6 +235,7 @@ class TestBrowserTools:
artifact = result.update["artifacts"][0] artifact = result.update["artifacts"][0]
assert artifact == "/mnt/user-data/outputs/Login_Page.png" assert artifact == "/mnt/user-data/outputs/Login_Page.png"
assert (outputs / "Login_Page.png").read_bytes() == b"\x89PNG\r\n\x1a\npng-bytes" assert (outputs / "Login_Page.png").read_bytes() == b"\x89PNG\r\n\x1a\npng-bytes"
session.screenshot_bytes.assert_awaited_once_with(full_page=False)
async def test_screenshot_errors_without_outputs_path(self): async def test_screenshot_errors_without_outputs_path(self):
session = MagicMock() session = MagicMock()
@ -318,6 +342,42 @@ async def test_live_frame_returns_jpeg_bytes_without_base64_expansion():
page.screenshot.assert_awaited_once_with(type="jpeg", quality=_LIVE_FRAME_JPEG_QUALITY) page.screenshot.assert_awaited_once_with(type="jpeg", quality=_LIVE_FRAME_JPEG_QUALITY)
@pytest.mark.asyncio
async def test_screenshot_bytes_defaults_to_png_without_quality():
session = BrowserSession(
MagicMock(),
headless=True,
timeout_ms=1000,
viewport={"width": 1000, "height": 500},
)
page = MagicMock()
page.screenshot = AsyncMock(return_value=b"png")
session._ensure_page = AsyncMock(return_value=page)
shot = await session._screenshot_bytes(full_page=False, image_type="png", quality=None)
assert shot == b"png"
page.screenshot.assert_awaited_once_with(full_page=False, type="png")
@pytest.mark.asyncio
async def test_screenshot_bytes_forwards_jpeg_quality():
session = BrowserSession(
MagicMock(),
headless=True,
timeout_ms=1000,
viewport={"width": 1000, "height": 500},
)
page = MagicMock()
page.screenshot = AsyncMock(return_value=b"jpeg")
session._ensure_page = AsyncMock(return_value=page)
shot = await session._screenshot_bytes(full_page=False, image_type="jpeg", quality=80)
assert shot == b"jpeg"
page.screenshot.assert_awaited_once_with(full_page=False, type="jpeg", quality=80)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_input_dispatch_does_not_wait_for_live_frame(): async def test_input_dispatch_does_not_wait_for_live_frame():
session = BrowserSession( session = BrowserSession(