Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion astrbot/core/config/default.py
Original file line number Diff line number Diff line change
Expand Up @@ -4149,7 +4149,7 @@
"provider_settings.image_compress_options.max_size": {
"description": "最大边长",
"type": "int",
"hint": "压缩后图片的最长边,单位为像素。超过该尺寸时会按比例缩放。",
"hint": "压缩后图片的最长边,单位为像素,超出则按比例缩放。CUA 沙箱下输入图片不缩放,大图可能超出服务商上传限制。",
"condition": {
"provider_settings.image_compress_enabled": True,
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ async def prepare_request_images(
output_dir: Path,
prepared: dict[str, str | None],
quote_image_ref: str | None = None,
montage_max_size: int | None = None,
) -> None:
"""Replace current images on a working request and track their owned files.

Expand All @@ -36,6 +37,7 @@ async def prepare_request_images(
output_dir: Event working file directory, separate from the shared cache.
prepared: Per-request mapping reused after the request hook.
quote_image_ref: Optional input for the dedicated quote caption branch.
montage_max_size: Optional montage-specific limit; defaults to ``max_size``.
"""
req.image_urls = normalize_and_dedupe_strings(req.image_urls)
refs = list(req.image_urls)
Expand All @@ -52,7 +54,11 @@ async def prepare_request_images(
path = None
if enabled:
path = await prepare_model_image(
ref, max_size=max_size, output_dir=output_dir, quality=quality
ref,
max_size=max_size,
output_dir=output_dir,
quality=quality,
montage_max_size=montage_max_size,
)
if path:
event.track_temporary_local_file(path)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,9 @@
)
from .image_input import prepare_request_images

# Anthropic rejects images above 5 MB; OpenAI and Gemini allow roughly 20 MB.
_CUA_IMAGE_WARN_BYTES = 5 * 1024 * 1024


class InternalAgentSubStage(Stage):
async def initialize(self, ctx: PipelineContext) -> None:
Expand Down Expand Up @@ -243,9 +246,24 @@ async def process(
settings = self.ctx.astrbot_config["provider_settings"]
enabled = settings.get("image_compress_enabled", True) is not False
options = settings.get("image_compress_options", {})
max_size = normalize_model_image_max_size(
montage_max_size = normalize_model_image_max_size(
options.get("max_size") if isinstance(options, dict) else None
)
max_size = montage_max_size
sandbox_cfg = settings.get("sandbox")
cua_pixel_mode = (
settings.get("computer_use_runtime") == "sandbox"
and isinstance(sandbox_cfg, dict)
and sandbox_cfg.get("booter") == "cua"
)
if cua_pixel_mode:
# CUA pixel tools read coordinates 1:1 on stills, so the
# still-image resize is lifted; compliant images pass through
# byte-exact since lossy re-encoding would shift colors.
# Montages are never used for coordinates and keep the
# configured cap, which bounds the 3x3 canvas. Oversized
# passthrough images warn below.
max_size = 1_000_000
Comment thread
piexian marked this conversation as resolved.
Comment thread
piexian marked this conversation as resolved.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (bug_risk): CUA still images whose longest edge exceeds 1,000,000 pixels are still resized by _convert_image_bytes_sync, so their original geometry and 1:1 coordinate mapping are not preserved.

Triggers: When a CUA input image has a longest edge greater than 1,000,000 pixels.

Suggested fix: Use an explicit no-resize path for CUA stills, or otherwise validate that the configured sentinel cannot be reached by real inputs before claiming geometry is always preserved.

Comment on lines +259 to +266

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep caption-only images under the configured cap

When a CUA sandbox is configured but the selected chat provider lacks image support, this lifts the resize cap before _ensure_img_caption or the quoted-image caption branch sends the image to the dedicated caption provider. Those images are never used by coordinate-based CUA tools, so a typical multi-megabyte phone photo that was previously reduced to image_compress_options.max_size is now uploaded at full resolution and may exceed the caption provider's limits or make captioning fail. Apply the uncapped size only to images that will reach the image-capable CUA chat provider, while retaining the configured cap for caption-only calls.

Useful? React with 👍 / 👎.

quality = (
options.get("quality") if isinstance(options, dict) else None
)
Expand Down Expand Up @@ -275,6 +293,7 @@ async def process(
output_dir=output_dir,
prepared=prepared,
quote_image_ref=quote_image_ref,
montage_max_size=montage_max_size,
)
await _process_quote_message(
event,
Expand Down Expand Up @@ -341,7 +360,26 @@ async def process(
quality=quality,
output_dir=output_dir,
prepared=prepared,
montage_max_size=montage_max_size,
)
if cua_pixel_mode:
oversized = []
for path in {p for p in prepared.values() if p}:
try:
size = Path(path).stat().st_size
except OSError:
continue
if size > _CUA_IMAGE_WARN_BYTES:
oversized.append(size)
if oversized:
logger.warning(
"CUA session sends %d image(s) larger than %d MB "
"(largest %.1f MB) without resize; this may exceed "
"provider image upload limits.",
len(oversized),
_CUA_IMAGE_WARN_BYTES // 1048576,
max(oversized) / 1048576,
)
# apply reset
if reset_coro:
await reset_coro
Expand Down
13 changes: 11 additions & 2 deletions astrbot/core/utils/media_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -1366,14 +1366,20 @@ async def prepare_model_image(
max_size: int,
output_dir: Path,
quality: int = IMAGE_COMPRESS_DEFAULT_QUALITY,
montage_max_size: int | None = None,
) -> str | None:
"""Prepare a single local model-ready image for the caller to own until consumption.

Args:
image_ref: Source reference accepted by MediaResolver.
max_size: Validated longest-edge limit for stills and animation montages.
max_size: Validated longest-edge limit for stills.
output_dir: Directory for independent request-owned working files.
quality: JPEG output quality in the range 1-100.
montage_max_size: Optional longest-edge limit for animation montages.
CUA sessions lift the still-image cap to keep pixel coordinates 1:1,
but montages are never used for coordinates, so callers pass the
configured limit here to keep the 3x3 canvas bounded. Defaults to
``max_size``.

Returns:
An existing JPEG or PNG path, or None for a recoverable input or write
Expand All @@ -1386,7 +1392,10 @@ async def prepare_model_image(
frame_count = await asyncio.to_thread(_inspect_image, image_bytes)
if frame_count > 1:
converted_bytes, _ = await asyncio.to_thread(
_extract_animation_montage_sync, image_bytes, max_size, quality
_extract_animation_montage_sync,
image_bytes,
montage_max_size if montage_max_size is not None else max_size,
quality,
)
else:
converted_bytes = await asyncio.to_thread(
Expand Down
3 changes: 3 additions & 0 deletions docs/en/providers/image-formats.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@
- Stills and montages share `image_compress_options.max_size` (default 1280). Small images are not enlarged.
- Disabling compression keeps generic localization and reading, without resizing, transcoding, sampling or consulting the derived-image cache.


> [!TIP]
> When the computer-use runtime is `sandbox` and the sandbox booter is `cua`, input images are not resized, so pixel coordinates read by coordinate-based tools stay 1:1. Compliant images pass through byte-exact (no lossy re-encoding, avoiding JPEG color shifts); format conversion for other formats and animation montages still apply. Images above roughly 5 MB may exceed provider image upload limits and trigger a warning in the logs.
The Agent receives readable local paths. Original image files and event components keep their original content, and attachment text continues to reference the source image. Providers only read/encode references and assemble their protocols.

## Errors and lifetime
Expand Down
3 changes: 3 additions & 0 deletions docs/zh/providers/image-formats.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@

**最大边长**默认为 1280 像素,静图和动图拼图共用此设置,小图不会放大。**压缩质量**(1-100,默认 95)控制 JPEG 输出的画质与体积。


> [!TIP]
> 当“电脑使用”运行时为沙箱且沙箱 Booter 为 CUA 时,为避免像素坐标漂移,输入图片不按最大边长缩放,合规图片原样发送(不重新编码,避免 JPEG 变色);格式转换与动图拼图等其余处理仍然生效。超过约 5 MB 的大图可能超出服务商的图片体积限制,此时日志会给出警告。
## 处理方式

- **合规静图原样发送**:已是 JPEG/PNG、方向正常且未超过最大边长的图片不做任何改动。
Expand Down
92 changes: 92 additions & 0 deletions tests/test_process_stage_images.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import base64
import copy
import inspect
import os
from contextlib import nullcontext
from pathlib import Path
from types import SimpleNamespace
Expand Down Expand Up @@ -235,6 +236,97 @@ async def test_profile_toggle_and_preprocess_to_first_model(
assert "image_settings" not in harness.provider.text_chat.await_args.kwargs


@pytest.mark.asyncio
@pytest.mark.parametrize(
("runtime", "booter", "fmt"),
[
("sandbox", "cua", "PNG"),
("sandbox", "cua", "WEBP"),
("sandbox", "shipyard_neo", "PNG"),
("local", "cua", "PNG"),
],
)
async def test_cua_runtime_keeps_input_image_geometry(
harness, tmp_path, runtime, booter, fmt
):
"""CUA pixel tools read coordinates 1:1, so only the resize is lifted."""
path = tmp_path / f"big.{fmt.lower()}"
PILImage.new("RGB", (200, 100), "red").save(path, fmt)
original = path.read_bytes()
harness.config["provider_settings"]["computer_use_runtime"] = runtime
harness.config["provider_settings"]["sandbox"] = {"booter": booter}
event = make_event([Image(file=str(path))], text="")
await process_event(harness, event, preprocess_first=True)
assert len(harness.captured) == 1
req = harness.captured[0].req
gated = runtime == "sandbox" and booter == "cua"
with PILImage.open(req.image_urls[0]) as image:
assert image.size == ((200, 100) if gated else (90, 45))
if gated:
# A compliant source is reused byte-exact; other formats are still
# re-encoded (to JPEG) without any resize.
expected = fmt if fmt in {"JPEG", "PNG"} else "JPEG"
assert image.format == expected
if gated and fmt == "PNG":
assert Path(req.image_urls[0]).read_bytes() == original


@pytest.mark.asyncio
@pytest.mark.parametrize(
("runtime", "booter", "big"),
[
("sandbox", "cua", True),
("sandbox", "cua", False),
("sandbox", "shipyard_neo", True),
],
)
async def test_cua_oversize_image_warns(
harness, tmp_path, monkeypatch, runtime, booter, big
):
"""Byte-exact CUA passthrough warns when an image may exceed upload limits."""
dims = (1500, 1500) if big else (60, 30)
path = tmp_path / "shot.png"
if big:
# Noise stays incompressible, keeping the PNG above the 5 MB threshold.
PILImage.frombytes("RGB", dims, os.urandom(dims[0] * dims[1] * 3)).save(
path, "PNG"
)
else:
PILImage.new("RGB", dims, "red").save(path, "PNG")
original = path.read_bytes()
harness.config["provider_settings"]["computer_use_runtime"] = runtime
harness.config["provider_settings"]["sandbox"] = {"booter": booter}
warning = MagicMock()
monkeypatch.setattr(internal.logger, "warning", warning)
event = make_event([Image(file=str(path))], text="")
await process_event(harness, event, preprocess_first=True)
assert len(harness.captured) == 1
req = harness.captured[0].req
gated = runtime == "sandbox" and booter == "cua"
if gated:
assert Path(req.image_urls[0]).read_bytes() == original
warned = any("upload limits" in str(call) for call in warning.call_args_list)
assert warned == (big and gated)


@pytest.mark.asyncio
async def test_cua_montage_keeps_configured_cap(harness, tmp_path):
"""Animated inputs keep the configured montage cap under CUA pixel mode."""
path = tmp_path / "anim.gif"
frames = [PILImage.new("RGB", (600, 400), c) for c in ("red", "green", "blue")]
frames[0].save(path, "GIF", save_all=True, append_images=frames[1:])
harness.config["provider_settings"]["computer_use_runtime"] = "sandbox"
harness.config["provider_settings"]["sandbox"] = {"booter": "cua"}
event = make_event([Image(file=str(path))], text="")
await process_event(harness, event, preprocess_first=True)
assert len(harness.captured) == 1
req = harness.captured[0].req
with PILImage.open(req.image_urls[0]) as image:
# With the configured cap 90, 600x400 frames produce a 90x60 montage;
# the CUA still-image passthrough must not unbound the montage canvas.
assert max(image.size) <= 90


@pytest.mark.asyncio
async def test_profile_reload_and_concurrent_requests(harness, tmp_path):
source = source_image(tmp_path)
Expand Down
Loading