From 4068b70fd4bbfb57c0f18634a7242b992ea2a792 Mon Sep 17 00:00:00 2001 From: Soulter <905617992@qq.com> Date: Thu, 17 Sep 2026 21:06:11 +0800 Subject: [PATCH] fix: reuse compliant model image files without copying --- .../method/agent_sub_stages/image_input.py | 5 ++- astrbot/core/utils/media_utils.py | 25 +++++++---- changelogs/unreleased.md | 2 +- docs/en/providers/image-formats.md | 2 +- docs/zh/providers/image-formats.md | 2 +- tests/test_model_image_preparation.py | 11 +++-- tests/test_process_stage_images.py | 43 +++++++++++++++++++ 7 files changed, 73 insertions(+), 17 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 68176c0d0..6473b90de 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 @@ -48,8 +48,9 @@ async def prepare_request_images( output_dir=output_dir, ) if image: - path, is_montage = image - event.track_temporary_local_file(path) + path, is_montage, needs_cleanup = image + if needs_cleanup: + event.track_temporary_local_file(path) has_montage |= is_montage prepared[ref] = path if path: diff --git a/astrbot/core/utils/media_utils.py b/astrbot/core/utils/media_utils.py index 78c524460..a9f6eaa0b 100644 --- a/astrbot/core/utils/media_utils.py +++ b/astrbot/core/utils/media_utils.py @@ -1197,8 +1197,8 @@ async def prepare_model_image( *, max_size: int, output_dir: Path, -) -> tuple[str, bool] | None: - """Prepare a single local model-ready image for the caller to own until consumption. +) -> tuple[str, bool, bool] | None: + """Prepare an image, reusing compliant local files without copying them. Args: image_ref: Source reference accepted by MediaResolver. @@ -1206,16 +1206,23 @@ async def prepare_model_image( output_dir: Directory for event-owned working files. Returns: - A preview path and whether it is an animation montage, or None for a - recoverable input or write failure. The caller must delete the preview - after use. The original image is never overwritten. + The image path, whether it is an animation montage, and whether the caller + must delete the file after use. Existing compliant local files are borrowed; + newly materialized sources and encoded previews are owned by the caller. + Returns None for a recoverable input or write failure. """ try: async with MediaResolver(image_ref, media_type="image").as_path() as source: image_bytes = await asyncio.to_thread(source.read_bytes) - converted_bytes, is_montage = await asyncio.to_thread( - _prepare_model_image_sync, image_bytes, max_size - ) + converted_bytes, is_montage = await asyncio.to_thread( + _prepare_model_image_sync, image_bytes, max_size + ) + if converted_bytes is image_bytes: + # The encoder returns the original bytes object for passthroughs. + # Transfer ownership only if the resolver materialized this file. + needs_cleanup = bool(source.cleanup_paths) + source.detach() + return str(source.path), is_montage, needs_cleanup # Publish the working file synchronously after encoding, so cancellation # cannot leave an untracked background write alive after this call. output_dir.mkdir(parents=True, exist_ok=True) @@ -1230,7 +1237,7 @@ async def prepare_model_image( except BaseException: output_path.unlink(missing_ok=True) raise - return str(output_path), is_montage + return str(output_path), is_montage, True except Exception as exc: if not is_recoverable_image_error(exc): raise diff --git a/changelogs/unreleased.md b/changelogs/unreleased.md index 98d591b29..18407dac0 100644 --- a/changelogs/unreleased.md +++ b/changelogs/unreleased.md @@ -1,4 +1,4 @@ # Unreleased -- Local Agent input images are always prepared as JPEG/PNG files strictly below 512 KiB. Transparent previews retain PNG alpha; animations become 3×3 montages. Original attachment paths remain available to tools, and event-owned previews are deleted after use without a shared conversion cache. +- Local Agent input images are always prepared as JPEG/PNG files strictly below 512 KiB. Compliant local images are reused without copying. Transparent previews retain PNG alpha; animations become 3×3 montages. Original attachment paths remain available to tools, and event-owned previews are deleted after use without a shared conversion cache. - Configuration mapping: **Enable image compression** (`provider_settings.image_compress_enabled`) is replaced by always-on preparation; **JPEG quality** (`provider_settings.image_compress_options.quality`) is replaced by automatic size control; **Maximum edge length** (`provider_settings.image_compress_options.max_size`) remains available. User attachments in CUA sessions follow the same limits. diff --git a/docs/en/providers/image-formats.md b/docs/en/providers/image-formats.md index 144ffba4b..37b81b7ac 100644 --- a/docs/en/providers/image-formats.md +++ b/docs/en/providers/image-formats.md @@ -24,7 +24,7 @@ Values of the removed settings in existing configuration files no longer affect ## Original files and preview lifetime -`image_urls` and image content parts use the prepared preview files. The attachment paths shown to the model for file access continue to point to the **original images**, so tools can read the originals. Original files and event image components are not overwritten. +`image_urls` and image content parts reuse compliant local JPEG/PNG files directly; no working copy is created. Images requiring conversion use prepared preview files. The attachment paths shown to the model for file access continue to point to the **original images**, so tools can read the originals. Original files and event image components are not overwritten. Preview files are owned by the current event and deleted when the event finishes, including failure or cancellation. There is no shared image-conversion cache. Repeated references within one request reuse the same preview, including the preparation pass after the request hook. diff --git a/docs/zh/providers/image-formats.md b/docs/zh/providers/image-formats.md index 07f0d2403..1becc23b0 100644 --- a/docs/zh/providers/image-formats.md +++ b/docs/zh/providers/image-formats.md @@ -24,7 +24,7 @@ CUA 会话中的用户输入图片也遵守这些限制;截图及其他工具 ## 原图路径与文件清理 -`image_urls` 及图片内容块使用处理后的预览文件。提供给模型、供工具读取文件的附件说明仍然指向**原始图片路径**,不会替换为预览路径。原始文件和事件中的图片组件均不被覆盖。 +`image_urls` 及图片内容块直接复用合规的本地 JPEG/PNG 文件,不创建工作副本;需要转换的图片才使用处理后的预览文件。提供给模型、供工具读取文件的附件说明仍然指向**原始图片路径**,不会替换为预览路径。原始文件和事件中的图片组件均不被覆盖。 模型预览文件归当前事件所有,事件完成、失败或取消后清理,不再保留跨请求图片转换缓存。同一请求中重复出现的图片引用复用同一份预览,请求 Hook 前后的准备过程也会去重。 diff --git a/tests/test_model_image_preparation.py b/tests/test_model_image_preparation.py index f9f63fb79..716af845f 100644 --- a/tests/test_model_image_preparation.py +++ b/tests/test_model_image_preparation.py @@ -44,12 +44,17 @@ async def test_oversized_opaque_stills_become_jpeg_without_mutating_source( @pytest.mark.asyncio @pytest.mark.parametrize("fmt", ["JPEG", "PNG"]) -async def test_compliant_stills_pass_through_byte_identical(tmp_path, fmt): +@pytest.mark.parametrize("file_uri", [False, True]) +async def test_compliant_stills_reuse_source_without_copying(tmp_path, fmt, file_uri): source = tmp_path / f"ok.{fmt.lower()}" Image.new("RGB", (200, 100), "red").save(source, fmt) original = source.read_bytes() - path = await _prepare_image_path(str(source), max_size=1280, output_dir=tmp_path) - assert path and Path(path).read_bytes() == original + output = tmp_path / "output" + prepared = await media.prepare_model_image( + source.as_uri() if file_uri else str(source), max_size=1280, output_dir=output + ) + assert prepared == (str(source), False, False) + assert not output.exists() assert source.read_bytes() == original diff --git a/tests/test_process_stage_images.py b/tests/test_process_stage_images.py index 1f57c383c..b6c9c12a3 100644 --- a/tests/test_process_stage_images.py +++ b/tests/test_process_stage_images.py @@ -225,6 +225,11 @@ async def test_legacy_toggle_does_not_disable_preparation( ) assert harness.provider.text_chat.await_count == 1 assert "image_settings" not in harness.provider.text_chat.await_args.kwargs + visual_path = Path(req.image_urls[0]) + assert (visual_path == source) == (fmt in {"JPEG", "PNG"}) + event.cleanup_temporary_local_files() + assert source.read_bytes() == original + assert visual_path.exists() == (fmt in {"JPEG", "PNG"}) @pytest.mark.asyncio @@ -745,6 +750,44 @@ async def test_localized_reference_lifetime_and_ownership( assert source.is_file() +@pytest.mark.asyncio +@pytest.mark.parametrize("fmt", ["JPEG", "PNG"]) +@pytest.mark.parametrize("reference", ["data", "base64", "http", "file", "path"]) +async def test_compliant_plugin_images_reuse_localized_file_with_correct_ownership( + harness, tmp_path, monkeypatch, fmt, reference +): + source = source_image(tmp_path, fmt) + original = source.read_bytes() + encoded = base64.b64encode(original).decode() + refs = { + "data": f"data:image/{fmt.lower()};base64," + encoded, + "base64": "base64://" + encoded, + "http": "https://example.com/source", + "file": source.as_uri(), + "path": str(source), + } + + async def download(url, target): + Path(target).write_bytes(original) + + monkeypatch.setattr(media, "download_file", download) + event = make_event() + event.set_extra( + "provider_request", + ProviderRequest(prompt="image", image_urls=[refs[reference]]), + ) + await process_event(harness, event) + path = Path(harness.captured[0].req.image_urls[0]) + assert path.read_bytes() == original + borrowed = reference in {"file", "path"} + assert (path == source) == borrowed + assert event._temporary_local_files == ([] if borrowed else [str(path)]) + assert set(harness.work.rglob("*")) == (set() if borrowed else {path}) + event.cleanup_temporary_local_files() + assert path.exists() == borrowed + assert source.read_bytes() == original + + @pytest.mark.asyncio async def test_two_plugin_requests_on_one_event_are_prepared_independently( harness, tmp_path, monkeypatch