Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from astrbot.core.platform.astr_message_event import AstrMessageEvent
from astrbot.core.provider.entities import ProviderRequest
from astrbot.core.utils.media_utils import (
MODEL_IMAGE_MAX_BYTES,
MediaResolver,
is_recoverable_image_error,
prepare_model_image,
Expand All @@ -25,6 +26,7 @@ async def prepare_request_images(
prepared: dict[str, str | None],
quote_image_ref: str | None = None,
montage_max_size: int | None = None,
max_bytes: int | None = MODEL_IMAGE_MAX_BYTES,
) -> None:
"""Replace current images on a working request and track their owned files.

Expand All @@ -38,6 +40,7 @@ async def prepare_request_images(
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``.
max_bytes: Optional byte budget per still; ``None`` keeps them byte-exact.
"""
req.image_urls = normalize_and_dedupe_strings(req.image_urls)
refs = list(req.image_urls)
Expand All @@ -59,6 +62,7 @@ async def prepare_request_images(
output_dir=output_dir,
quality=quality,
montage_max_size=montage_max_size,
max_bytes=max_bytes,
)
if path:
event.track_temporary_local_file(path)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
from astrbot.core.utils.media_utils import (
IMAGE_COMPRESS_DEFAULT_QUALITY,
MODEL_IMAGE_MAX_BYTES,
normalize_model_image_max_size,
)
from astrbot.core.utils.metrics import Metric
Expand Down Expand Up @@ -264,6 +265,9 @@ async def process(
# configured cap, which bounds the 3x3 canvas. Oversized
# passthrough images warn below.
max_size = 1_000_000
# CUA stills stay byte-exact to avoid JPEG color shifts, so the
# byte budget applies only elsewhere; that path warns instead.
max_bytes = None if cua_pixel_mode else MODEL_IMAGE_MAX_BYTES
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
quality = (
options.get("quality") if isinstance(options, dict) else None
)
Expand Down Expand Up @@ -294,6 +298,7 @@ async def process(
prepared=prepared,
quote_image_ref=quote_image_ref,
montage_max_size=montage_max_size,
max_bytes=max_bytes,
)
await _process_quote_message(
event,
Expand Down Expand Up @@ -361,6 +366,7 @@ async def process(
output_dir=output_dir,
prepared=prepared,
montage_max_size=montage_max_size,
max_bytes=max_bytes,
)
if cua_pixel_mode:
oversized = []
Expand Down
58 changes: 51 additions & 7 deletions astrbot/core/utils/media_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -1014,13 +1014,28 @@ def _inspect_image(image_bytes: bytes) -> int:
return frame_count


_IMAGE_CONVERT_CACHE_VERSION = "v9-icc"
_IMAGE_CONVERT_CACHE_VERSION = "v10-bytes"
"""Bump when conversion output semantics change (modes, transparency, sizing)
so stale cache entries produced by older code are never served."""

MODEL_IMAGE_PNG_FALLBACK_MAX_BYTES = 1024 * 1024
"""PNG outputs larger than this are flattened onto white and re-encoded as JPEG."""

MODEL_IMAGE_MAX_BYTES = 1024 * 1024
"""Byte budget for one prepared still image.

The longest-edge cap bounds pixels, not compressed size: a compliant 1280x1280
noisy PNG already exceeds 6 MiB, which providers reject before the model ever
sees it. ``None`` disables the budget and keeps oversized stills byte-exact.
"""

MODEL_IMAGE_SHRINK_ATTEMPTS = 5
"""Extra encodes allowed while fitting a still into its byte budget.

Embedded metadata survives every quality step and a large canvas survives every
budget, so a bounded number of retries is needed rather than one quality step.
"""


def normalize_model_image_max_size(value: object) -> int:
"""Normalize the model image longest-edge cap.
Expand Down Expand Up @@ -1067,13 +1082,17 @@ def _encode_image_frame_bytes(
image: PILImage.Image,
max_size: int | None = None,
quality: int = IMAGE_COMPRESS_DEFAULT_QUALITY,
keep_metadata: bool = True,
) -> bytes:
"""Encode a display-oriented frame as JPEG, or PNG when it carries transparency.

Args:
image: Opened source frame, which is never mutated.
max_size: Optional longest-edge limit; smaller images are not enlarged.
quality: JPEG output quality in the range 1-100.
keep_metadata: Whether to carry the source ICC profile into the output.
Callers fitting a byte budget drop it, because the profile survives
every quality step and can keep a payload over budget on its own.

Returns:
Encoded single-frame JPEG or PNG bytes. A PNG larger than
Expand Down Expand Up @@ -1106,7 +1125,7 @@ def _encode_image_frame_bytes(
# JPEG saving does not auto-embed the source ICC profile like PNG does;
# attach it explicitly, but only when the color space survived intact
# (conversions like CMYK -> RGB invalidate the source profile).
icc_profile = image.info.get("icc_profile")
icc_profile = image.info.get("icc_profile") if keep_metadata else None
if image.mode not in {"RGB", "RGBA", "L", "LA", "P", "1", "I", "I;16"}:
icc_profile = None
save_kwargs = {"icc_profile": icc_profile} if icc_profile else {}
Expand Down Expand Up @@ -1224,35 +1243,57 @@ def _publish_image_cache_atomic(


def _convert_image_bytes_sync(
source_bytes: bytes, max_size: int, quality: int
source_bytes: bytes,
max_size: int,
quality: int,
max_bytes: int | None = MODEL_IMAGE_MAX_BYTES,
) -> bytes:
"""Normalize a validated still image with an optional derived cache.

Args:
source_bytes: Encoded source bytes already checked by _inspect_image.
max_size: Longest-edge limit in pixels.
quality: JPEG output quality in the range 1-100.
max_bytes: Optional byte budget; ``None`` keeps oversized compliant
stills byte-exact instead of bounding their payload.

Returns:
Single-frame JPEG or PNG bytes. An oriented JPEG or PNG within the size
limit is reused unchanged; anything else is re-encoded.
Single-frame JPEG or PNG bytes. An oriented JPEG or PNG inside both the
pixel and the byte limit is reused unchanged; anything else is
re-encoded and shrunk until it fits the byte budget, dropping embedded
metadata and finally the canvas when quality alone is not enough.
"""
with PILImage.open(io.BytesIO(source_bytes)) as image:
if (
image.format in {"PNG", "JPEG"}
and image.getexif().get(274, 1) == 1
and max(image.size) <= max_size
and (max_bytes is None or len(source_bytes) <= max_bytes)
):
return source_bytes
cache_key = _image_convert_cache_key(
source_bytes,
f"{_IMAGE_CONVERT_CACHE_VERSION}|convert|s={max_size}|q={quality}",
f"{_IMAGE_CONVERT_CACHE_VERSION}|convert|s={max_size}"
f"|q={quality}|b={max_bytes}",
)
output_path = _image_convert_cache_dir() / (cache_key + ".img")
cached = _read_valid_cached_image_bytes(output_path)
if cached is not None:
return cached
encoded = _encode_image_frame_bytes(image, max_size=max_size, quality=quality)
for _ in range(MODEL_IMAGE_SHRINK_ATTEMPTS):
if max_bytes is None or len(encoded) <= max_bytes:
break
# The pixel cap bounds geometry and the quality bounds artefacts, but
# neither bounds the payload alone: embedded metadata survives any
# quality, and a large canvas survives any budget. Drop the metadata
# with the first retry, then derate and finally shrink the canvas.
quality = max(1, quality * max_bytes // len(encoded))
if quality <= 1:
max_size = max(max_size // 2, ANIMATED_MONTAGE_GRID)
encoded = _encode_image_frame_bytes(
image, max_size=max_size, quality=quality, keep_metadata=False
)
_publish_image_cache_atomic(output_path, encoded)
return encoded

Expand Down Expand Up @@ -1367,6 +1408,7 @@ async def prepare_model_image(
output_dir: Path,
quality: int = IMAGE_COMPRESS_DEFAULT_QUALITY,
montage_max_size: int | None = None,
max_bytes: int | None = MODEL_IMAGE_MAX_BYTES,
) -> str | None:
"""Prepare a single local model-ready image for the caller to own until consumption.

Expand All @@ -1380,6 +1422,8 @@ async def prepare_model_image(
but montages are never used for coordinates, so callers pass the
configured limit here to keep the 3x3 canvas bounded. Defaults to
``max_size``.
max_bytes: Optional byte budget for stills. CUA sessions pass ``None``
because their compliant stills must stay byte-exact.

Returns:
An existing JPEG or PNG path, or None for a recoverable input or write
Expand All @@ -1399,7 +1443,7 @@ async def prepare_model_image(
)
else:
converted_bytes = await asyncio.to_thread(
_convert_image_bytes_sync, image_bytes, max_size, quality
_convert_image_bytes_sync, image_bytes, max_size, quality, max_bytes
)
# Publish the working file synchronously after encoding, so cancellation
# cannot leave an untracked background write alive after this call.
Expand Down
5 changes: 3 additions & 2 deletions docs/en/providers/image-formats.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,16 @@

## Preparation rules

- Compliant still images are sent unchanged: already JPEG or PNG, correctly oriented, and within the size limit.
- Compliant still images are sent unchanged: already JPEG or PNG, correctly oriented, inside the pixel limit and inside the 1 MB byte budget.
- Compliant stills above the byte budget are re-encoded to bound the request payload: pixel dimensions are preserved first, oversized metadata is dropped, and the JPEG quality is derated; only when the lowest quality is still oversized is the canvas scaled down. The pixel limit bounds dimensions, not compressed size, so without a byte budget such a still can reach tens of megabytes and be rejected by the provider before the model sees it.
- Other still images are orientation-corrected, resized and re-encoded as needed. Opaque images become JPEG, controlled by `image_compress_options.quality` (default 95), with high-bit-depth samples normalized to 8-bit. Images with transparency become PNG; a PNG larger than 1 MB is flattened onto a white background and re-encoded as JPEG to bound the payload size.
- Animations are detected from their frames, including GIF, animated WebP and APNG. Up to nine evenly sampled frames, including the first and last, become one white 3×3 grid. Unused cells stay white; an APNG independent cover is excluded.
- 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.
> 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, and no byte budget); 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
5 changes: 3 additions & 2 deletions docs/zh/providers/image-formats.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,11 @@


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

- **合规静图原样发送**:已是 JPEG/PNG、方向正常且未超过最大边长的图片不做任何改动。
- **合规静图原样发送**:已是 JPEG/PNG、方向正常、未超过最大边长且不超过 1 MB 的图片不做任何改动。
- **超出体积预算的静图**:其余条件合规但超过 1 MB 的静图会重新编码以控制请求体积:优先保持像素尺寸、丢弃过大的元数据并逐步降低 JPEG 质量;只有质量降到最低仍超标时才按比例缩小尺寸。最大边长限制的是像素而不是压缩后体积,不设体积预算时合规静图可以原样发送数十 MB,被服务商直接拒绝。
- **其余静图按需转换**:方向异常、尺寸超限或格式不通用的图片会修正方向、缩放后重新编码。无透明度的图片输出 JPEG(16 位等高位深图片会归一化为 8 位);含透明度的图片输出 PNG,若 PNG 超过 1 MB 则扁平化为白底 JPEG,控制发送体积。
- **动图**:从 GIF、动画 WebP、APNG 等图片中均匀选取最多 9 帧,包含首尾动画帧,生成白底 3×3 拼图;不足 9 帧的格子留白。单帧 GIF 和静态 WebP 按静图处理。
- **无法读取或解码的图片**:跳过该图片,其他文字和正常图片继续处理。
Expand Down
43 changes: 43 additions & 0 deletions tests/test_model_image_preparation.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import asyncio
import errno
import os
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import Mock
Expand Down Expand Up @@ -51,6 +52,48 @@ async def test_compliant_stills_pass_through_byte_identical(tmp_path, fmt):
assert source.read_bytes() == original


@pytest.mark.asyncio
async def test_compliant_still_over_byte_budget_is_reencoded(tmp_path):
source = tmp_path / "noisy.png"
Image.frombytes("RGBA", (1280, 1280), os.urandom(1280 * 1280 * 4)).save(source)
original = source.read_bytes()
assert len(original) > media.MODEL_IMAGE_MAX_BYTES
path = await media.prepare_model_image(
str(source), max_size=1280, output_dir=tmp_path
)
assert path and Path(path).read_bytes() != original
assert Path(path).stat().st_size <= media.MODEL_IMAGE_MAX_BYTES
assert source.read_bytes() == original


@pytest.mark.asyncio
async def test_oversized_metadata_does_not_keep_still_over_budget(tmp_path):
source = tmp_path / "profiled.jpg"
Image.new("RGB", (200, 100), "red").save(
source, icc_profile=b"\x00" * (2 * 1024 * 1024)
)
original = source.read_bytes()
assert len(original) > media.MODEL_IMAGE_MAX_BYTES
path = await media.prepare_model_image(
str(source), max_size=1280, output_dir=tmp_path
)
assert path and Path(path).stat().st_size <= media.MODEL_IMAGE_MAX_BYTES
assert source.read_bytes() == original


@pytest.mark.asyncio
async def test_disabled_byte_budget_keeps_oversized_still_byte_exact(tmp_path):
source = tmp_path / "noisy.png"
Image.frombytes("RGBA", (1280, 1280), os.urandom(1280 * 1280 * 4)).save(source)
original = source.read_bytes()
assert len(original) > media.MODEL_IMAGE_MAX_BYTES
path = await media.prepare_model_image(
str(source), max_size=1280, output_dir=tmp_path, max_bytes=None
)
assert path and Path(path).read_bytes() == original
assert source.read_bytes() == original


@pytest.mark.asyncio
async def test_rotated_jpeg_is_normalized_to_upright_jpeg(tmp_path):
source = tmp_path / "rotated.jpg"
Expand Down
Loading