mirror of
https://github.com/AstrBotDevs/AstrBot.git
synced 2026-10-16 15:06:04 +08:00
fix: reuse compliant model image files without copying
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ CUA 会话中的用户输入图片也遵守这些限制;截图及其他工具
|
||||
|
||||
## 原图路径与文件清理
|
||||
|
||||
`image_urls` 及图片内容块使用处理后的预览文件。提供给模型、供工具读取文件的附件说明仍然指向**原始图片路径**,不会替换为预览路径。原始文件和事件中的图片组件均不被覆盖。
|
||||
`image_urls` 及图片内容块直接复用合规的本地 JPEG/PNG 文件,不创建工作副本;需要转换的图片才使用处理后的预览文件。提供给模型、供工具读取文件的附件说明仍然指向**原始图片路径**,不会替换为预览路径。原始文件和事件中的图片组件均不被覆盖。
|
||||
|
||||
模型预览文件归当前事件所有,事件完成、失败或取消后清理,不再保留跨请求图片转换缓存。同一请求中重复出现的图片引用复用同一份预览,请求 Hook 前后的准备过程也会去重。
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user