From b467964052c8252d36dbb63f698378e4f04ef6a8 Mon Sep 17 00:00:00 2001 From: Strands <148874030+MostimaBridges@users.noreply.github.com> Date: Tue, 15 Sep 2026 09:14:50 +0800 Subject: [PATCH 1/2] fix(core): bound model image payload by bytes, not only by pixels The still-image passthrough in _convert_image_bytes_sync only checked format, EXIF orientation and the longest edge, so a compliant PNG/JPEG was sent byte-exact no matter how large it was. A noisy 1280x1280 PNG already exceeds 6 MiB, and CUA sessions lift the edge cap entirely. Providers then reject the whole request with 413 before the model sees the image, surfacing as an opaque All chat models failed error. Add MODEL_IMAGE_MAX_BYTES (1 MiB) as a per-still byte budget that gates the passthrough. Stills over budget keep their pixel dimensions but are re-encoded, derating the JPEG quality once when the result is still oversized. CUA sandbox sessions pass None to keep compliant stills byte-exact, since that path deliberately trades payload size for pixel fidelity and already warns about oversized images. Refs #10089 --- .../method/agent_sub_stages/image_input.py | 4 ++ .../method/agent_sub_stages/internal.py | 5 +++ astrbot/core/utils/media_utils.py | 40 ++++++++++++++++--- docs/en/providers/image-formats.md | 5 ++- docs/zh/providers/image-formats.md | 5 ++- tests/test_model_image_preparation.py | 28 +++++++++++++ 6 files changed, 77 insertions(+), 10 deletions(-) diff --git a/astrbot/core/pipeline/process_stage/method/agent_sub_stages/image_input.py b/astrbot/core/pipeline/process_stage/method/agent_sub_stages/image_input.py index 61e237b8bd..41026843fb 100644 --- a/astrbot/core/pipeline/process_stage/method/agent_sub_stages/image_input.py +++ b/astrbot/core/pipeline/process_stage/method/agent_sub_stages/image_input.py @@ -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, @@ -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. @@ -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) @@ -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) diff --git a/astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py b/astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py index 322506e3a8..2ae9b6224a 100644 --- a/astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py +++ b/astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py @@ -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 @@ -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 quality = ( options.get("quality") if isinstance(options, dict) else None ) @@ -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, diff --git a/astrbot/core/utils/media_utils.py b/astrbot/core/utils/media_utils.py index ff552293d5..5ea30bfa5b 100644 --- a/astrbot/core/utils/media_utils.py +++ b/astrbot/core/utils/media_utils.py @@ -1014,13 +1014,21 @@ 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. +""" + def normalize_model_image_max_size(value: object) -> int: """Normalize the model image longest-edge cap. @@ -1224,7 +1232,10 @@ 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. @@ -1232,27 +1243,41 @@ def _convert_image_bytes_sync( 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, derating the JPEG quality once when the result is still over + budget. """ 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) + if max_bytes is not None and len(encoded) > max_bytes and quality > 1: + # Re-encoding keeps the geometry but not necessarily the payload, so + # derate once instead of sending a request the provider rejects. + derated = max(1, quality * max_bytes // len(encoded)) + if derated < quality: + encoded = _encode_image_frame_bytes( + image, max_size=max_size, quality=derated + ) _publish_image_cache_atomic(output_path, encoded) return encoded @@ -1367,6 +1392,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. @@ -1380,6 +1406,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 @@ -1399,7 +1427,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. diff --git a/docs/en/providers/image-formats.md b/docs/en/providers/image-formats.md index 0c9fb96eca..d9f515e68a 100644 --- a/docs/en/providers/image-formats.md +++ b/docs/en/providers/image-formats.md @@ -4,7 +4,8 @@ ## 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 keep their pixel dimensions but are re-encoded to bound the request payload, derating the JPEG quality once when the result is still oversized. 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. @@ -12,7 +13,7 @@ > [!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 diff --git a/docs/zh/providers/image-formats.md b/docs/zh/providers/image-formats.md index 4fe3997318..73fb9a37ad 100644 --- a/docs/zh/providers/image-formats.md +++ b/docs/zh/providers/image-formats.md @@ -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 按静图处理。 - **无法读取或解码的图片**:跳过该图片,其他文字和正常图片继续处理。 diff --git a/tests/test_model_image_preparation.py b/tests/test_model_image_preparation.py index 24488b88de..02b5a410b0 100644 --- a/tests/test_model_image_preparation.py +++ b/tests/test_model_image_preparation.py @@ -2,6 +2,7 @@ import asyncio import errno +import os from pathlib import Path from types import SimpleNamespace from unittest.mock import Mock @@ -51,6 +52,33 @@ 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_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" From 940f4167df6592c059c51cec356b01283b179f61 Mon Sep 17 00:00:00 2001 From: Strands <148874030+MostimaBridges@users.noreply.github.com> Date: Tue, 15 Sep 2026 10:09:01 +0800 Subject: [PATCH 2/2] fix(core): make the model image byte budget reachable and apply it on every pass Review follow-ups for the byte budget. Pass max_bytes to the second prepare_request_images call as well. Images added or replaced by an OnLLMRequestEvent hook were re-encoded with the default budget, so CUA sessions lost their byte-exact passthrough there. Keep shrinking until the payload actually fits. A single quality step cannot bound a payload that the embedded ICC profile alone keeps large, because the profile survives every quality setting. Retries now drop the metadata first, then derate the quality, and shrink the canvas only once the quality floor is reached. Refs #10089 --- .../method/agent_sub_stages/internal.py | 1 + astrbot/core/utils/media_utils.py | 38 +++++++++++++------ docs/en/providers/image-formats.md | 2 +- docs/zh/providers/image-formats.md | 2 +- tests/test_model_image_preparation.py | 15 ++++++++ 5 files changed, 45 insertions(+), 13 deletions(-) diff --git a/astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py b/astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py index 2ae9b6224a..d115660db2 100644 --- a/astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py +++ b/astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py @@ -366,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 = [] diff --git a/astrbot/core/utils/media_utils.py b/astrbot/core/utils/media_utils.py index 5ea30bfa5b..559872fd0a 100644 --- a/astrbot/core/utils/media_utils.py +++ b/astrbot/core/utils/media_utils.py @@ -1029,6 +1029,13 @@ def _inspect_image(image_bytes: bytes) -> int: 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. @@ -1075,6 +1082,7 @@ 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. @@ -1082,6 +1090,9 @@ def _encode_image_frame_bytes( 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 @@ -1114,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 {} @@ -1249,8 +1260,8 @@ def _convert_image_bytes_sync( Returns: 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, derating the JPEG quality once when the result is still over - budget. + 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 ( @@ -1270,14 +1281,19 @@ def _convert_image_bytes_sync( if cached is not None: return cached encoded = _encode_image_frame_bytes(image, max_size=max_size, quality=quality) - if max_bytes is not None and len(encoded) > max_bytes and quality > 1: - # Re-encoding keeps the geometry but not necessarily the payload, so - # derate once instead of sending a request the provider rejects. - derated = max(1, quality * max_bytes // len(encoded)) - if derated < quality: - encoded = _encode_image_frame_bytes( - image, max_size=max_size, quality=derated - ) + 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 diff --git a/docs/en/providers/image-formats.md b/docs/en/providers/image-formats.md index d9f515e68a..109d8604fb 100644 --- a/docs/en/providers/image-formats.md +++ b/docs/en/providers/image-formats.md @@ -5,7 +5,7 @@ ## Preparation rules - 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 keep their pixel dimensions but are re-encoded to bound the request payload, derating the JPEG quality once when the result is still oversized. 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. +- 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. diff --git a/docs/zh/providers/image-formats.md b/docs/zh/providers/image-formats.md index 73fb9a37ad..75697cc21a 100644 --- a/docs/zh/providers/image-formats.md +++ b/docs/zh/providers/image-formats.md @@ -14,7 +14,7 @@ ## 处理方式 - **合规静图原样发送**:已是 JPEG/PNG、方向正常、未超过最大边长且不超过 1 MB 的图片不做任何改动。 -- **超出体积预算的静图**:其余条件合规但超过 1 MB 的静图不改变像素尺寸,直接重新编码以控制请求体积,仍超标则再降低一档 JPEG 质量。最大边长限制的是像素而不是压缩后体积,不设体积预算时合规静图可以原样发送数十 MB,被服务商直接拒绝。 +- **超出体积预算的静图**:其余条件合规但超过 1 MB 的静图会重新编码以控制请求体积:优先保持像素尺寸、丢弃过大的元数据并逐步降低 JPEG 质量;只有质量降到最低仍超标时才按比例缩小尺寸。最大边长限制的是像素而不是压缩后体积,不设体积预算时合规静图可以原样发送数十 MB,被服务商直接拒绝。 - **其余静图按需转换**:方向异常、尺寸超限或格式不通用的图片会修正方向、缩放后重新编码。无透明度的图片输出 JPEG(16 位等高位深图片会归一化为 8 位);含透明度的图片输出 PNG,若 PNG 超过 1 MB 则扁平化为白底 JPEG,控制发送体积。 - **动图**:从 GIF、动画 WebP、APNG 等图片中均匀选取最多 9 帧,包含首尾动画帧,生成白底 3×3 拼图;不足 9 帧的格子留白。单帧 GIF 和静态 WebP 按静图处理。 - **无法读取或解码的图片**:跳过该图片,其他文字和正常图片继续处理。 diff --git a/tests/test_model_image_preparation.py b/tests/test_model_image_preparation.py index 02b5a410b0..84e949c4eb 100644 --- a/tests/test_model_image_preparation.py +++ b/tests/test_model_image_preparation.py @@ -66,6 +66,21 @@ async def test_compliant_still_over_byte_budget_is_reencoded(tmp_path): 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"