From 64ac460c7a850961dbaea72e9ff23ccea82ef71b Mon Sep 17 00:00:00 2001 From: huyan Date: Thu, 20 Aug 2026 20:38:37 +0800 Subject: [PATCH 01/21] feat(pixel-perfect): define tool contracts The application needs a minimal boundary for local grid detection and reconstruction. Add immutable result types and stable input, busy, and unavailable error classes. Later adapters can compose the native modules without importing business services. --- .../windup_app/server/pixel_perfect/errors.py | 17 +++++++++++++++++ .../windup_app/server/pixel_perfect/model.py | 18 ++++++++++++++++++ 2 files changed, 35 insertions(+) create mode 100644 backend/packages/app/src/windup_app/server/pixel_perfect/errors.py create mode 100644 backend/packages/app/src/windup_app/server/pixel_perfect/model.py diff --git a/backend/packages/app/src/windup_app/server/pixel_perfect/errors.py b/backend/packages/app/src/windup_app/server/pixel_perfect/errors.py new file mode 100644 index 00000000..f9e8a3d5 --- /dev/null +++ b/backend/packages/app/src/windup_app/server/pixel_perfect/errors.py @@ -0,0 +1,17 @@ +"""完美像素工具可以映射为稳定 API 结果的失败类型。""" + + +class PixelPerfectError(Exception): + """本地工具可预期失败的基类。""" + + +class PixelPerfectBusyError(PixelPerfectError): + pass + + +class PixelPerfectInputError(PixelPerfectError): + pass + + +class PixelPerfectUnavailableError(PixelPerfectError): + pass diff --git a/backend/packages/app/src/windup_app/server/pixel_perfect/model.py b/backend/packages/app/src/windup_app/server/pixel_perfect/model.py new file mode 100644 index 00000000..7f37859c --- /dev/null +++ b/backend/packages/app/src/windup_app/server/pixel_perfect/model.py @@ -0,0 +1,18 @@ +"""完美像素工具在检测、重建与 API 之间传递的最小契约。""" + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class GridDetection: + cols: int + rows: int + step_x: float + step_y: float + consensus: str + confidence: str + + +@dataclass(frozen=True) +class PixelPerfectResult(GridDetection): + png: bytes From c80dcd37326339e227fe0f5c791dcd64abd8a689 Mon Sep 17 00:00:00 2001 From: huyan Date: Thu, 20 Aug 2026 20:38:38 +0800 Subject: [PATCH 02/21] feat(pixel-perfect): add bounded native adapters The tool API must invoke fixed local binaries without unbounded subprocess resources. Add six-field detector parsing, explicit reconstructor arguments, output limits, timeouts, and exit classification. Native failures now map to controlled tool errors instead of leaking process behavior. --- .../windup_app/server/pixel_perfect/native.py | 175 ++++++++++++++++++ 1 file changed, 175 insertions(+) create mode 100644 backend/packages/app/src/windup_app/server/pixel_perfect/native.py diff --git a/backend/packages/app/src/windup_app/server/pixel_perfect/native.py b/backend/packages/app/src/windup_app/server/pixel_perfect/native.py new file mode 100644 index 00000000..75046c96 --- /dev/null +++ b/backend/packages/app/src/windup_app/server/pixel_perfect/native.py @@ -0,0 +1,175 @@ +"""两个独立原生模块的进程适配器。""" + +from collections.abc import Sequence +import json +import math +import subprocess +from threading import Thread + +from windup_app.server.pixel_perfect.errors import ( + PixelPerfectInputError, + PixelPerfectUnavailableError, +) +from windup_app.server.pixel_perfect.model import GridDetection + + +class NativeGridDetector: + def __init__(self, command: Sequence[str], *, timeout_seconds: float) -> None: + self._command = tuple(command) + self._timeout_seconds = timeout_seconds + + def detect(self, source: bytes) -> GridDetection: + output = _run( + (*self._command, "--full"), + source, + timeout_seconds=self._timeout_seconds, + stdout_limit=64 * 1024, + ) + if len(output) > 64 * 1024: + raise PixelPerfectUnavailableError("检测器返回数据过大") + try: + payload = json.loads(output) + except (UnicodeDecodeError, json.JSONDecodeError) as error: + raise PixelPerfectUnavailableError("检测器返回了无效 JSON") from error + expected = { + "cols", + "rows", + "step_x", + "step_y", + "consensus", + "confidence", + } + if not isinstance(payload, dict) or set(payload) != expected: + raise PixelPerfectUnavailableError("检测器返回字段不符合约定") + try: + result = GridDetection(**payload) + except TypeError as error: + raise PixelPerfectUnavailableError("检测器返回类型不符合约定") from error + if ( + not isinstance(result.cols, int) + or isinstance(result.cols, bool) + or not isinstance(result.rows, int) + or isinstance(result.rows, bool) + or result.cols < 1 + or result.rows < 1 + or not isinstance(result.step_x, (int, float)) + or isinstance(result.step_x, bool) + or not isinstance(result.step_y, (int, float)) + or isinstance(result.step_y, bool) + or not math.isfinite(result.step_x) + or not math.isfinite(result.step_y) + or result.step_x <= 0 + or result.step_y <= 0 + or not isinstance(result.consensus, str) + or result.confidence not in {"high", "medium", "low"} + ): + raise PixelPerfectUnavailableError("检测器返回值超出约定") + return result + + +class NativeGridReconstructor: + def __init__(self, command: Sequence[str], *, timeout_seconds: float) -> None: + self._command = tuple(command) + self._timeout_seconds = timeout_seconds + + def reconstruct(self, source: bytes, *, cols: int, rows: int, colors: int) -> bytes: + output = _run( + ( + *self._command, + "--cols", + str(cols), + "--rows", + str(rows), + "--colors", + str(colors), + ), + source, + timeout_seconds=self._timeout_seconds, + stdout_limit=32 * 1024 * 1024, + ) + if len(output) > 32 * 1024 * 1024: + raise PixelPerfectUnavailableError("重建器返回数据过大") + return output + + +def _run( + command: Sequence[str], + source: bytes, + *, + timeout_seconds: float, + stdout_limit: int, + stderr_limit: int = 64 * 1024, +) -> bytes: + if not command or timeout_seconds <= 0 or min(stdout_limit, stderr_limit) < 1: + raise ValueError("native command and positive timeout are required") + try: + process = subprocess.Popen( + command, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + except (FileNotFoundError, OSError) as error: + raise PixelPerfectUnavailableError("本地像素工具未安装或无法启动") from error + + stdout = bytearray() + stderr = bytearray() + overflow = [] + + def read_bounded(stream, target: bytearray, limit: int, name: str) -> None: + while chunk := stream.read(64 * 1024): + remaining = limit + 1 - len(target) + target.extend(chunk[:remaining]) + if len(target) > limit: + overflow.append(name) + process.kill() + break + stream.close() + + def write_input() -> None: + try: + process.stdin.write(source) + except (BrokenPipeError, OSError): + pass + finally: + process.stdin.close() + + threads = [ + Thread(target=write_input, daemon=True), + Thread( + target=read_bounded, + args=(process.stdout, stdout, stdout_limit, "stdout"), + daemon=True, + ), + Thread( + target=read_bounded, + args=(process.stderr, stderr, stderr_limit, "stderr"), + daemon=True, + ), + ] + for thread in threads: + thread.start() + try: + process.wait(timeout=timeout_seconds) + except subprocess.TimeoutExpired as error: + process.kill() + process.wait() + raise PixelPerfectUnavailableError("本地像素工具处理超时") from error + finally: + for thread in threads: + thread.join() + + if overflow: + raise PixelPerfectUnavailableError(f"本地像素工具 {overflow[0]} 超过资源上限") + if process.returncode < 0: + raise PixelPerfectUnavailableError( + f"本地像素工具被信号 {-process.returncode} 终止" + ) + if process.returncode == 1: + detail = stderr.decode("utf-8", errors="replace").strip()[:500] + raise PixelPerfectInputError(detail or "本地像素工具拒绝了输入") + if process.returncode != 0: + raise PixelPerfectUnavailableError( + f"本地像素工具异常退出(code={process.returncode})" + ) + return bytes(stdout) From 13aa8a0e612e1c4ba9dd269c3c45f2be7e03adbc Mon Sep 17 00:00:00 2001 From: huyan Date: Thu, 20 Aug 2026 20:38:52 +0800 Subject: [PATCH 03/21] feat(pixel-perfect): compose local grid tools Automatic and manual modes need one application boundary that remains outside generation. Compose detection and explicit reconstruction with finite parameters, bounded concurrency, and PNG validation. The tool can run independently while rejecting unsupported sub-three-pixel auto results. --- .../server/pixel_perfect/__init__.py | 32 +++++ .../server/pixel_perfect/factory.py | 25 ++++ .../server/pixel_perfect/service.py | 136 ++++++++++++++++++ 3 files changed, 193 insertions(+) create mode 100644 backend/packages/app/src/windup_app/server/pixel_perfect/__init__.py create mode 100644 backend/packages/app/src/windup_app/server/pixel_perfect/factory.py create mode 100644 backend/packages/app/src/windup_app/server/pixel_perfect/service.py diff --git a/backend/packages/app/src/windup_app/server/pixel_perfect/__init__.py b/backend/packages/app/src/windup_app/server/pixel_perfect/__init__.py new file mode 100644 index 00000000..7f8ebc90 --- /dev/null +++ b/backend/packages/app/src/windup_app/server/pixel_perfect/__init__.py @@ -0,0 +1,32 @@ +from windup_app.server.pixel_perfect.errors import ( + PixelPerfectBusyError, + PixelPerfectError, + PixelPerfectInputError, + PixelPerfectUnavailableError, +) +from windup_app.server.pixel_perfect.factory import create_pixel_perfect_tool +from windup_app.server.pixel_perfect.model import GridDetection, PixelPerfectResult +from windup_app.server.pixel_perfect.native import ( + NativeGridDetector, + NativeGridReconstructor, +) +from windup_app.server.pixel_perfect.service import ( + GridDetector, + GridReconstructor, + PixelPerfectTool, +) + +__all__ = [ + "GridDetection", + "GridDetector", + "GridReconstructor", + "NativeGridDetector", + "NativeGridReconstructor", + "PixelPerfectBusyError", + "PixelPerfectError", + "PixelPerfectInputError", + "PixelPerfectResult", + "PixelPerfectTool", + "PixelPerfectUnavailableError", + "create_pixel_perfect_tool", +] diff --git a/backend/packages/app/src/windup_app/server/pixel_perfect/factory.py b/backend/packages/app/src/windup_app/server/pixel_perfect/factory.py new file mode 100644 index 00000000..4bb5562a --- /dev/null +++ b/backend/packages/app/src/windup_app/server/pixel_perfect/factory.py @@ -0,0 +1,25 @@ +"""完美像素工具在应用 composition root 使用的本地装配。""" + +import os + +from windup_app.server.pixel_perfect.native import ( + NativeGridDetector, + NativeGridReconstructor, +) +from windup_app.server.pixel_perfect.service import PixelPerfectTool + + +def create_pixel_perfect_tool() -> PixelPerfectTool: + timeout_seconds = float(os.getenv("PIXEL_PERFECT_TIMEOUT_SECONDS", "30")) + max_concurrency = int(os.getenv("PIXEL_PERFECT_CONCURRENCY", "1")) + detector_bin = os.getenv("PIXEL_GRID_DETECTOR_BIN", "windup-pixel-grid-detector") + reconstructor_bin = os.getenv( + "PIXEL_GRID_RECONSTRUCTOR_BIN", "windup-pixel-grid-reconstructor" + ) + return PixelPerfectTool( + detector=NativeGridDetector((detector_bin,), timeout_seconds=timeout_seconds), + reconstructor=NativeGridReconstructor( + (reconstructor_bin,), timeout_seconds=timeout_seconds + ), + max_concurrency=max_concurrency, + ) diff --git a/backend/packages/app/src/windup_app/server/pixel_perfect/service.py b/backend/packages/app/src/windup_app/server/pixel_perfect/service.py new file mode 100644 index 00000000..20cd3eb3 --- /dev/null +++ b/backend/packages/app/src/windup_app/server/pixel_perfect/service.py @@ -0,0 +1,136 @@ +"""独立完美像素工具的编排边界。""" + +from io import BytesIO +import math +from threading import BoundedSemaphore +from typing import Protocol + +from PIL import Image, UnidentifiedImageError + +from windup_app.server.pixel_perfect.errors import ( + PixelPerfectBusyError, + PixelPerfectInputError, + PixelPerfectUnavailableError, +) +from windup_app.server.pixel_perfect.model import GridDetection, PixelPerfectResult + + +class GridDetector(Protocol): + def detect(self, source: bytes) -> GridDetection: ... + + +class GridReconstructor(Protocol): + def reconstruct( + self, source: bytes, *, cols: int, rows: int, colors: int + ) -> bytes: ... + + +class PixelPerfectTool: + def __init__( + self, + *, + detector: GridDetector, + reconstructor: GridReconstructor, + max_concurrency: int = 1, + ) -> None: + self._detector = detector + self._reconstructor = reconstructor + if max_concurrency < 1: + raise ValueError("max_concurrency must be at least 1") + self._slots = BoundedSemaphore(max_concurrency) + + def process( + self, + source: bytes, + *, + colors: int, + pixel_size: float | None, + ) -> PixelPerfectResult: + if not self._slots.acquire(blocking=False): + raise PixelPerfectBusyError("完美像素工具正在处理另一张图片") + try: + width, height = _image_dimensions(source) + if not 2 <= colors <= 64: + raise PixelPerfectInputError("colors 必须在 2 到 64 之间") + if pixel_size is None: + grid = self._detector.detect(source) + if grid.step_x < 3 or grid.step_y < 3: + raise PixelPerfectInputError( + "自动识别到的隐含像素小于 3px,请提供手动 pixel_size" + ) + else: + if not math.isfinite(pixel_size) or pixel_size < 1: + raise PixelPerfectInputError("pixel_size 必须是大于等于 1 的有限数") + cols = max(1, round(width / pixel_size)) + rows = max(1, round(height / pixel_size)) + grid = GridDetection( + cols=cols, + rows=rows, + step_x=width / cols, + step_y=height / rows, + consensus="forced", + confidence="forced", + ) + if not (1 <= grid.cols <= width and 1 <= grid.rows <= height): + raise PixelPerfectUnavailableError("检测器返回了超出图片范围的网格") + png = self._reconstructor.reconstruct( + source, + cols=grid.cols, + rows=grid.rows, + colors=colors, + ) + _validate_reconstruction(png, grid.cols, grid.rows) + return PixelPerfectResult( + png=png, + cols=grid.cols, + rows=grid.rows, + step_x=grid.step_x, + step_y=grid.step_y, + consensus=grid.consensus, + confidence=grid.confidence, + ) + finally: + self._slots.release() + + +def _image_dimensions(source: bytes) -> tuple[int, int]: + if len(source) > 10 * 1024 * 1024: + raise PixelPerfectInputError("图片不能超过 10 MB") + try: + with Image.open(BytesIO(source)) as image: + width, height = image.size + image_format = image.format + except ( + Image.DecompressionBombError, + Image.DecompressionBombWarning, + UnidentifiedImageError, + OSError, + ) as error: + raise PixelPerfectInputError("来源不是可解码的图片") from error + if image_format not in {"PNG", "JPEG"}: + raise PixelPerfectInputError("当前只支持 PNG/JPEG 图片") + if min(width, height) < 16: + raise PixelPerfectInputError("图片最短边不能小于 16px") + pixel_count = width * height + if pixel_count > 4_000_000: + raise PixelPerfectInputError("图片像素数不能超过 4000000") + return width, height + + +def _validate_reconstruction(png: bytes, cols: int, rows: int) -> None: + try: + with Image.open(BytesIO(png)) as image: + if image.format != "PNG" or image.size != (cols, rows): + raise PixelPerfectUnavailableError( + "重建器返回的 PNG 尺寸与显式网格不一致" + ) + image.load() + except PixelPerfectUnavailableError: + raise + except ( + Image.DecompressionBombError, + Image.DecompressionBombWarning, + UnidentifiedImageError, + OSError, + ) as error: + raise PixelPerfectUnavailableError("重建器返回了无效 PNG") from error From 313a5e809d1099708476125a57b2ce2e0a0d5f0b Mon Sep 17 00:00:00 2001 From: huyan Date: Thu, 20 Aug 2026 20:39:06 +0800 Subject: [PATCH 04/21] feat(pixel-perfect): expose standalone file API Browsers need an explicit endpoint that never touches storage or generation workflows. Add streamed request limits, local-tool error mapping, PNG metadata headers, and composition-root registration. The authenticated endpoint now returns the rebuilt image directly with bounded upload concurrency. --- .../app/src/windup_app/bootstrap/app.py | 19 ++++ .../src/windup_app/web/api/pixel_perfect.py | 87 +++++++++++++++++++ .../web/middleware/pixel_perfect_limits.py | 79 +++++++++++++++++ 3 files changed, 185 insertions(+) create mode 100644 backend/packages/app/src/windup_app/web/api/pixel_perfect.py create mode 100644 backend/packages/app/src/windup_app/web/middleware/pixel_perfect_limits.py diff --git a/backend/packages/app/src/windup_app/bootstrap/app.py b/backend/packages/app/src/windup_app/bootstrap/app.py index 7a706aa3..5cdc379c 100644 --- a/backend/packages/app/src/windup_app/bootstrap/app.py +++ b/backend/packages/app/src/windup_app/bootstrap/app.py @@ -29,15 +29,20 @@ from windup_app.web.api.auth import router as auth_router from windup_app.web.api.character import router as character_router from windup_app.server.orchestrator import task_repo +from windup_app.server.pixel_perfect import create_pixel_perfect_tool from windup_app.server.orchestrator.render3d_service import default_operations, precheck_master from windup_app.web.api.generation import router as generation_router from windup_app.web.api.media import router as media_router +from windup_app.web.api.pixel_perfect import router as pixel_perfect_router from windup_app.web.api.project import router as project_router from windup_app.web.api.quota import router as quota_router from windup_app.web.api.render3d import router as render3d_router from windup_app.web.api.workflow_run import router as workflow_run_router from windup_app.web.handler.exception_handlers import register_exception_handlers from windup_app.web.middleware.auth import AuthMiddleware +from windup_app.web.middleware.pixel_perfect_limits import ( + PixelPerfectRequestLimitsMiddleware, +) from windup_framework.mq.publisher import MqPublisher from windup_framework.mq.relay import relay_pending_messages from windup_framework.providers import create_chat_model @@ -102,6 +107,7 @@ def create_app() -> FastAPI: app = FastAPI(title="windup", version="0.1.0", lifespan=_lifespan) app.state.mq_publisher = MqPublisher() app.state.chat_model_factory = create_chat_model + app.state.pixel_perfect_tool = create_pixel_perfect_tool() # 起名器在 composition root 注入,避免 web→character.service 碰到 ai_engine。 # LangChainCharacterNamer 构造期不创建 ChatOpenAI;缺 AI_API_KEY 时应用仍能启动。 # 测试若已注入假 namer,不要覆盖。 @@ -114,6 +120,10 @@ def health() -> dict[str, str]: # 中间件(add_middleware 后加的先执行:请求先进 CORS → 再进 Auth → 最后到路由) app.add_middleware(AuthMiddleware) + app.add_middleware( + PixelPerfectRequestLimitsMiddleware, + max_concurrency=int(os.getenv("PIXEL_PERFECT_CONCURRENCY", "1")), + ) app.add_middleware( CORSMiddleware, allow_origins=_cors_origins(), @@ -121,12 +131,21 @@ def health() -> dict[str, str]: allow_credentials=True, allow_methods=["*"], allow_headers=["*"], + expose_headers=[ + "X-Pixel-Cols", + "X-Pixel-Rows", + "X-Pixel-Step-X", + "X-Pixel-Step-Y", + "X-Pixel-Consensus", + "X-Pixel-Confidence", + ], ) app.include_router(auth_router) app.include_router(project_router) app.include_router(character_router) app.include_router(workflow_run_router) app.include_router(media_router) + app.include_router(pixel_perfect_router) app.include_router(generation_router) app.include_router(quota_router) app.include_router(render3d_router) diff --git a/backend/packages/app/src/windup_app/web/api/pixel_perfect.py b/backend/packages/app/src/windup_app/web/api/pixel_perfect.py new file mode 100644 index 00000000..5ab8ecf8 --- /dev/null +++ b/backend/packages/app/src/windup_app/web/api/pixel_perfect.py @@ -0,0 +1,87 @@ +"""独立完美像素工具 API;只接收文件并直接返回本地 PNG。""" + +import asyncio + +from fastapi import APIRouter, File, Form, Request, UploadFile +from fastapi.responses import Response +from pydantic import BaseModel +from windup_common.enums.biz_code import BizCode +from windup_common.exceptions import BizException + +from windup_app.server.pixel_perfect import ( + PixelPerfectBusyError, + PixelPerfectInputError, + PixelPerfectUnavailableError, +) + + +router = APIRouter(prefix="/tools", tags=["tools"]) +MAX_UPLOAD_BYTES = 10 * 1024 * 1024 +_SIGNATURES = { + "image/png": b"\x89PNG\r\n\x1a\n", + "image/jpeg": b"\xff\xd8\xff", +} + + +class ToolErrorResponse(BaseModel): + code: int + message: str + data: object | None = None + + +@router.post( + "/pixel-perfect", + response_class=Response, + responses={ + 200: { + "model": ToolErrorResponse, + "content": {"image/png": {}}, + } + }, +) +async def pixel_perfect_file( + request: Request, + file: UploadFile = File(...), + colors: int = Form(32, ge=2, le=64), + pixel_size: float | None = Form(None, ge=1, allow_inf_nan=False), +) -> Response: + """显式调用本地工具;不会被任何生成流程自动触发。""" + signature = _SIGNATURES.get(file.content_type or "") + if signature is None: + raise BizException("当前只支持 PNG/JPEG 图片", code=BizCode.BAD_REQUEST) + source = bytearray() + while chunk := await file.read(64 * 1024): + if len(source) + len(chunk) > MAX_UPLOAD_BYTES: + raise BizException("图片不能超过 10 MB", code=BizCode.BAD_REQUEST) + source.extend(chunk) + if not source.startswith(signature): + raise BizException("文件内容与声明的图片类型不匹配", code=BizCode.BAD_REQUEST) + tool = getattr(request.app.state, "pixel_perfect_tool", None) + if tool is None: + raise BizException("完美像素工具未装配", code=BizCode.MODEL_UNAVAILABLE) + try: + result = await asyncio.to_thread( + tool.process, + bytes(source), + colors=colors, + pixel_size=pixel_size, + ) + except PixelPerfectInputError as error: + raise BizException(str(error), code=BizCode.BAD_REQUEST) from error + except PixelPerfectBusyError as error: + raise BizException(str(error), code=BizCode.TOO_MANY_REQUESTS) from error + except PixelPerfectUnavailableError as error: + raise BizException(str(error), code=BizCode.MODEL_UNAVAILABLE) from error + return Response( + content=result.png, + media_type="image/png", + headers={ + "Content-Disposition": 'attachment; filename="pixel-perfect.png"', + "X-Pixel-Cols": str(result.cols), + "X-Pixel-Rows": str(result.rows), + "X-Pixel-Step-X": str(result.step_x), + "X-Pixel-Step-Y": str(result.step_y), + "X-Pixel-Consensus": result.consensus, + "X-Pixel-Confidence": result.confidence, + }, + ) diff --git a/backend/packages/app/src/windup_app/web/middleware/pixel_perfect_limits.py b/backend/packages/app/src/windup_app/web/middleware/pixel_perfect_limits.py new file mode 100644 index 00000000..34f55463 --- /dev/null +++ b/backend/packages/app/src/windup_app/web/middleware/pixel_perfect_limits.py @@ -0,0 +1,79 @@ +"""在 multipart 落盘前限制完美像素工具的请求体与并发。""" + +import asyncio + +from starlette.responses import JSONResponse +from windup_common.enums.biz_code import BizCode +from windup_common.result import Response + + +class _BodyTooLarge(Exception): + pass + + +class PixelPerfectRequestLimitsMiddleware: + def __init__( + self, + app, + *, + max_body_bytes: int = 11 * 1024 * 1024, + max_concurrency: int = 1, + ) -> None: + if max_body_bytes < 1 or max_concurrency < 1: + raise ValueError("pixel-perfect request limits must be positive") + self.app = app + self.max_body_bytes = max_body_bytes + self.max_concurrency = max_concurrency + self._active = 0 + self._lock = asyncio.Lock() + + async def __call__(self, scope, receive, send) -> None: + if scope["type"] != "http" or not ( + scope["method"] == "POST" and scope["path"] == "/tools/pixel-perfect" + ): + await self.app(scope, receive, send) + return + + async with self._lock: + if self._active >= self.max_concurrency: + await self._reject( + scope, + receive, + send, + "完美像素工具正在处理另一张图片", + BizCode.TOO_MANY_REQUESTS, + ) + return + self._active += 1 + + received = 0 + + async def bounded_receive(): + nonlocal received + message = await receive() + if message["type"] == "http.request": + received += len(message.get("body", b"")) + if received > self.max_body_bytes: + raise _BodyTooLarge + return message + + try: + await self.app(scope, bounded_receive, send) + except _BodyTooLarge: + await self._reject( + scope, + receive, + send, + "请求体不能超过 11 MB", + BizCode.BAD_REQUEST, + ) + finally: + async with self._lock: + self._active -= 1 + + @staticmethod + async def _reject(scope, receive, send, message: str, code: BizCode) -> None: + response = JSONResponse( + Response.fail(message, code=code).model_dump(mode="json") + ) + await response(scope, receive, send) From 02fb019956047fbb20d393e0a04294983dfe3f95 Mon Sep 17 00:00:00 2001 From: huyan Date: Thu, 20 Aug 2026 20:39:20 +0800 Subject: [PATCH 05/21] build(pixel-perfect): declare image inspection dependency The app package inspects upload and reconstruction dimensions at its own boundary. Declare Pillow directly and update only the corresponding workspace lock metadata. Runtime dependency ownership no longer relies on the unrelated AI engine package. --- backend/packages/app/pyproject.toml | 1 + backend/uv.lock | 2 ++ 2 files changed, 3 insertions(+) diff --git a/backend/packages/app/pyproject.toml b/backend/packages/app/pyproject.toml index 52c4de6a..9cfc937b 100644 --- a/backend/packages/app/pyproject.toml +++ b/backend/packages/app/pyproject.toml @@ -12,6 +12,7 @@ dependencies = [ "pydantic[email]>=2.7", "sqlalchemy>=2.0", "python-multipart>=0.0.9", + "pillow>=10.4", ] [project.scripts] diff --git a/backend/uv.lock b/backend/uv.lock index 732fcad6..1fcc0bf6 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -2148,6 +2148,7 @@ version = "0.1.0" source = { editable = "packages/app" } dependencies = [ { name = "fastapi" }, + { name = "pillow" }, { name = "pydantic", extra = ["email"] }, { name = "python-multipart" }, { name = "sqlalchemy" }, @@ -2160,6 +2161,7 @@ dependencies = [ [package.metadata] requires-dist = [ { name = "fastapi", specifier = ">=0.115" }, + { name = "pillow", specifier = ">=10.4" }, { name = "pydantic", extras = ["email"], specifier = ">=2.7" }, { name = "python-multipart", specifier = ">=0.0.9" }, { name = "sqlalchemy", specifier = ">=2.0" }, From 57eb3186cf644baf2416d15958b94d6a11dc2db3 Mon Sep 17 00:00:00 2001 From: huyan Date: Thu, 20 Aug 2026 20:39:47 +0800 Subject: [PATCH 06/21] build(pixel-perfect): enforce pipeline isolation The new tool must remain separate from existing generation and infrastructure modules. Add bidirectional forbidden-import contracts for the tool, orchestrator, generation API, and worker. Future coupling attempts now fail the existing architecture gate. --- backend/pyproject.toml | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 0bb5cbc1..29071248 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -42,6 +42,30 @@ type = "forbidden" source_modules = ["windup_app.web", "windup_app.worker"] forbidden_modules = ["windup_ai_engine"] +[[tool.importlinter.contracts]] +name = "独立完美像素工具不依赖生成和业务基础设施" +type = "forbidden" +source_modules = [ + "windup_app.server.pixel_perfect", + "windup_app.web.api.pixel_perfect", +] +forbidden_modules = [ + "windup_ai_engine", + "windup_framework", + "windup_app.server.media", + "windup_app.server.orchestrator", +] + +[[tool.importlinter.contracts]] +name = "现有生成管线不依赖独立完美像素工具" +type = "forbidden" +source_modules = [ + "windup_app.server.orchestrator", + "windup_app.web.api.generation", + "windup_app.worker", +] +forbidden_modules = ["windup_app.server.pixel_perfect"] + # ── pytest 配置 ───────────────────────────────────────────────────── [tool.pytest.ini_options] markers = [ From a30a0aaf43a874fb8701289f90e99d17863f4100 Mon Sep 17 00:00:00 2001 From: huyan Date: Thu, 20 Aug 2026 20:40:01 +0800 Subject: [PATCH 07/21] build(pixel-perfect): package native tool binaries Production needs both standalone Rust modules without shipping a Rust runtime or source tree. Build each locked crate in a separate stage and copy only binaries plus MIT licenses into the image. The Python runtime can invoke fixed local tools through explicit environment paths. --- backend/Dockerfile | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/backend/Dockerfile b/backend/Dockerfile index 4761de95..31904fd8 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,7 +1,16 @@ # ── 后端 Dockerfile ────────────────────────────────────────────────── # 多阶段构建:builder 装依赖 → runtime 只拷贝产物,镜像更小 -# ── 阶段 1: 构建 ── +# ── 阶段 0: 构建两个独立的本地像素工具 ── +FROM rust:1.89-slim AS pixel-tools-builder + +WORKDIR /build +COPY native/pixel_grid_detector/ pixel_grid_detector/ +COPY native/pixel_grid_reconstructor/ pixel_grid_reconstructor/ +RUN cargo build --release --locked --manifest-path pixel_grid_detector/Cargo.toml \ + && cargo build --release --locked --manifest-path pixel_grid_reconstructor/Cargo.toml + +# ── 阶段 1: 构建 Python 环境 ── FROM python:3.12-slim AS builder # 安装 uv(比 pip 快 10x) @@ -38,6 +47,10 @@ WORKDIR /app # 从 builder 拷贝虚拟环境和包 COPY --from=builder /app/.venv /app/.venv COPY --from=builder /app/packages /app/packages +COPY --from=pixel-tools-builder /build/pixel_grid_detector/target/release/windup-pixel-grid-detector /usr/local/bin/windup-pixel-grid-detector +COPY --from=pixel-tools-builder /build/pixel_grid_reconstructor/target/release/windup-pixel-grid-reconstructor /usr/local/bin/windup-pixel-grid-reconstructor +COPY native/pixel_grid_detector/LICENSE /licenses/pixel-grid-detector/LICENSE +COPY native/pixel_grid_reconstructor/LICENSE /licenses/pixel-grid-reconstructor/LICENSE # 把 venv/bin 加入 PATH ENV PATH="/app/.venv/bin:$PATH" @@ -46,6 +59,8 @@ ENV PATH="/app/.venv/bin:$PATH" ENV WINDUP_HOST=0.0.0.0 ENV WINDUP_PORT=8000 ENV WINDUP_RELOAD=false +ENV PIXEL_GRID_DETECTOR_BIN=/usr/local/bin/windup-pixel-grid-detector +ENV PIXEL_GRID_RECONSTRUCTOR_BIN=/usr/local/bin/windup-pixel-grid-reconstructor EXPOSE 8000 From 38d1185856c9fddb6a2ce113b0b06f8226ab63f1 Mon Sep 17 00:00:00 2001 From: huyan Date: Thu, 20 Aug 2026 20:40:13 +0800 Subject: [PATCH 08/21] test(pixel-perfect): cover standalone tool boundaries The explicit API needs regression evidence for composition, resources, and failure mapping. Cover uploads, manual bypass, sub-three-pixel rejection, native limits, concurrency, CORS, and OpenAPI. Tool regressions now fail without exercising storage, generation, databases, or external services. --- backend/tests/test_pixel_perfect_api.py | 461 ++++++++++++++++++++++++ 1 file changed, 461 insertions(+) create mode 100644 backend/tests/test_pixel_perfect_api.py diff --git a/backend/tests/test_pixel_perfect_api.py b/backend/tests/test_pixel_perfect_api.py new file mode 100644 index 00000000..d664da97 --- /dev/null +++ b/backend/tests/test_pixel_perfect_api.py @@ -0,0 +1,461 @@ +"""独立完美像素工具的 API 与编排契约。""" + +import asyncio +from io import BytesIO +import json +import signal +import struct +import sys +import zlib +from concurrent.futures import ThreadPoolExecutor +from threading import Event +from types import SimpleNamespace + +from PIL import Image +from windup_common.enums.biz_code import BizCode + +from windup_app.server.pixel_perfect import ( + GridDetection, + NativeGridDetector, + NativeGridReconstructor, + PixelPerfectInputError, + PixelPerfectBusyError, + PixelPerfectTool, + PixelPerfectUnavailableError, +) +from windup_app.web.middleware.pixel_perfect_limits import ( + PixelPerfectRequestLimitsMiddleware, +) + + +def _png_bytes(width: int = 8, height: int = 8) -> bytes: + image = Image.new("RGBA", (width, height), (180, 90, 40, 255)) + output = BytesIO() + image.save(output, "PNG") + return output.getvalue() + + +def _oversized_png_header(width: int, height: int) -> bytes: + ihdr = struct.pack(">IIBBBBB", width, height, 8, 6, 0, 0, 0) + chunk = b"IHDR" + ihdr + return ( + b"\x89PNG\r\n\x1a\n" + + struct.pack(">I", len(ihdr)) + + chunk + + struct.pack(">I", zlib.crc32(chunk)) + ) + + +class _RecordingTool: + def __init__(self) -> None: + self.calls: list[tuple[bytes, int, float | None]] = [] + + def process(self, source: bytes, *, colors: int, pixel_size: float | None): + self.calls.append((source, colors, pixel_size)) + return SimpleNamespace( + png=_png_bytes(2, 2), + cols=2, + rows=2, + step_x=4.0, + step_y=4.0, + consensus="forced", + confidence="forced", + ) + + +def test_pixel_perfect_file_endpoint_returns_png_without_business_storage(auth_client): + tool = _RecordingTool() + auth_client.app.state.pixel_perfect_tool = tool + source = _png_bytes() + + response = auth_client.post( + "/tools/pixel-perfect", + files={"file": ("source.png", source, "image/png")}, + data={"colors": "16", "pixel_size": "4"}, + ) + + assert response.status_code == 200 + assert response.headers["content-type"] == "image/png" + assert response.headers["x-pixel-cols"] == "2" + assert response.headers["x-pixel-rows"] == "2" + assert response.headers["x-pixel-consensus"] == "forced" + assert response.content == _png_bytes(2, 2) + assert tool.calls == [(source, 16, 4.0)] + + +def test_pixel_perfect_endpoint_rejects_unsupported_or_mislabeled_files(auth_client): + tool = _RecordingTool() + auth_client.app.state.pixel_perfect_tool = tool + + unsupported = auth_client.post( + "/tools/pixel-perfect", + files={"file": ("source.webp", b"RIFFxxxxWEBP", "image/webp")}, + ) + mislabeled = auth_client.post( + "/tools/pixel-perfect", + files={"file": ("source.png", b"not-a-png", "image/png")}, + ) + + assert unsupported.json()["code"] == BizCode.BAD_REQUEST + assert mislabeled.json()["code"] == BizCode.BAD_REQUEST + assert tool.calls == [] + + +def test_pixel_perfect_endpoint_bounds_the_uploaded_bytes(auth_client): + tool = _RecordingTool() + auth_client.app.state.pixel_perfect_tool = tool + + response = auth_client.post( + "/tools/pixel-perfect", + files={ + "file": ( + "source.png", + b"\x89PNG\r\n\x1a\n" + bytes(10 * 1024 * 1024), + "image/png", + ) + }, + ) + + assert response.json()["code"] == BizCode.BAD_REQUEST + assert tool.calls == [] + + +def test_pixel_perfect_metadata_headers_are_visible_to_the_browser(auth_client): + auth_client.app.state.pixel_perfect_tool = _RecordingTool() + + response = auth_client.post( + "/tools/pixel-perfect", + headers={"origin": "http://127.0.0.1:5173"}, + files={"file": ("source.png", _png_bytes(), "image/png")}, + ) + + exposed = { + name.strip().lower() + for name in response.headers["access-control-expose-headers"].split(",") + } + assert { + "x-pixel-cols", + "x-pixel-rows", + "x-pixel-step-x", + "x-pixel-step-y", + "x-pixel-consensus", + "x-pixel-confidence", + } <= exposed + + +class _GridDetector: + def __init__(self, result: GridDetection) -> None: + self.result = result + self.calls: list[bytes] = [] + + def detect(self, source: bytes) -> GridDetection: + self.calls.append(source) + return self.result + + +class _GridReconstructor: + def __init__(self) -> None: + self.calls: list[tuple[bytes, int, int, int]] = [] + + def reconstruct(self, source: bytes, *, cols: int, rows: int, colors: int) -> bytes: + self.calls.append((source, cols, rows, colors)) + return _png_bytes(cols, rows) + + +def _detected_grid(*, step_x: float = 4.0, step_y: float = 4.0) -> GridDetection: + return GridDetection( + cols=8, + rows=6, + step_x=step_x, + step_y=step_y, + consensus="arbitrated", + confidence="medium", + ) + + +def test_tool_auto_mode_passes_only_the_detected_grid_to_reconstruction(): + source = _png_bytes(32, 24) + detector = _GridDetector(_detected_grid()) + reconstructor = _GridReconstructor() + tool = PixelPerfectTool(detector=detector, reconstructor=reconstructor) + + result = tool.process(source, colors=32, pixel_size=None) + + assert detector.calls == [source] + assert reconstructor.calls == [(source, 8, 6, 32)] + assert (result.cols, result.rows) == (8, 6) + assert result.consensus == "arbitrated" + + +def test_tool_manual_pixel_size_bypasses_detection(): + source = _png_bytes(32, 24) + detector = _GridDetector(_detected_grid()) + reconstructor = _GridReconstructor() + tool = PixelPerfectTool(detector=detector, reconstructor=reconstructor) + + result = tool.process(source, colors=16, pixel_size=4) + + assert detector.calls == [] + assert reconstructor.calls == [(source, 8, 6, 16)] + assert (result.step_x, result.step_y) == (4.0, 4.0) + assert (result.consensus, result.confidence) == ("forced", "forced") + + +def test_tool_does_not_auto_process_a_sub_three_pixel_detection(): + detector = _GridDetector(_detected_grid(step_x=2.8)) + reconstructor = _GridReconstructor() + tool = PixelPerfectTool(detector=detector, reconstructor=reconstructor) + + try: + tool.process(_png_bytes(32, 24), colors=32, pixel_size=None) + except PixelPerfectInputError as error: + assert "小于 3px" in str(error) + else: + raise AssertionError("sub-three-pixel auto mode must be rejected") + + assert reconstructor.calls == [] + + +def test_tool_rejects_non_finite_manual_pixel_size(): + tool = PixelPerfectTool( + detector=_GridDetector(_detected_grid()), + reconstructor=_GridReconstructor(), + ) + + for value in (float("inf"), float("-inf"), float("nan")): + try: + tool.process(_png_bytes(32, 24), colors=32, pixel_size=value) + except PixelPerfectInputError: + pass + else: + raise AssertionError("manual pixel_size must be finite") + + +def test_tool_maps_decompression_bombs_to_input_errors(): + tool = PixelPerfectTool( + detector=_GridDetector(_detected_grid()), + reconstructor=_GridReconstructor(), + ) + + try: + tool.process( + _oversized_png_header(100_000, 100_000), + colors=32, + pixel_size=None, + ) + except PixelPerfectInputError: + pass + else: + raise AssertionError("decompression bomb must be rejected") + + +def test_tool_validates_the_complete_reconstruction_png_and_grid_size(): + class InvalidReconstructor: + def __init__(self, output: bytes) -> None: + self.output = output + + def reconstruct(self, source, *, cols, rows, colors): + return self.output + + for output in (b"\x89PNG\r\n\x1a\nnot-a-png", _png_bytes(2, 2)): + tool = PixelPerfectTool( + detector=_GridDetector(_detected_grid()), + reconstructor=InvalidReconstructor(output), + ) + try: + tool.process(_png_bytes(32, 24), colors=32, pixel_size=None) + except PixelPerfectUnavailableError: + pass + else: + raise AssertionError("invalid reconstruction must be rejected") + + +def test_native_detector_reads_stdin_and_parses_the_six_field_contract(): + payload = { + "cols": 8, + "rows": 6, + "step_x": 4.0, + "step_y": 4.0, + "consensus": "arbitrated", + "confidence": "medium", + } + code = ( + "import json,sys; sys.stdin.buffer.read(); " + f"print(json.dumps({json.dumps(payload)}))" + ) + detector = NativeGridDetector((sys.executable, "-c", code), timeout_seconds=1) + + result = detector.detect(_png_bytes(32, 24)) + + assert result == GridDetection(**payload) + + +def test_native_reconstructor_passes_only_explicit_grid_arguments(): + output = _png_bytes(8, 6) + code = ( + "import sys; data=sys.stdin.buffer.read(); " + "expected=['--cols','8','--rows','6','--colors','16']; " + "sys.exit(2) if sys.argv[1:] != expected else sys.stdout.buffer.write(data)" + ) + reconstructor = NativeGridReconstructor( + (sys.executable, "-c", code), timeout_seconds=1 + ) + + result = reconstructor.reconstruct(output, cols=8, rows=6, colors=16) + + assert result == output + + +def test_native_process_timeout_is_reported_as_tool_unavailable(): + detector = NativeGridDetector( + (sys.executable, "-c", "import time; time.sleep(1)"), + timeout_seconds=0.01, + ) + + try: + detector.detect(_png_bytes(32, 24)) + except PixelPerfectUnavailableError as error: + assert "超时" in str(error) + else: + raise AssertionError("native timeout must be reported") + + +def test_native_process_output_is_bounded_while_the_child_is_running(): + detector = NativeGridDetector( + ( + sys.executable, + "-c", + "import sys; sys.stdin.buffer.read(); sys.stdout.buffer.write(b'x'*70000)", + ), + timeout_seconds=1, + ) + + try: + detector.detect(_png_bytes(32, 24)) + except PixelPerfectUnavailableError as error: + assert "stdout" in str(error) + else: + raise AssertionError("oversized native output must be rejected") + + +def test_native_signal_exit_is_tool_unavailable_not_bad_input(): + detector = NativeGridDetector( + ( + sys.executable, + "-c", + ( + "import os,signal,sys; sys.stdin.buffer.read(); " + "os.kill(os.getpid(), signal.SIGTERM)" + ), + ), + timeout_seconds=1, + ) + + try: + detector.detect(_png_bytes(32, 24)) + except PixelPerfectUnavailableError as error: + assert str(signal.SIGTERM) in str(error) + else: + raise AssertionError("signal exit must be unavailable") + + +def test_endpoint_maps_local_tool_failures_to_stable_business_codes(auth_client): + class FailingTool: + def __init__(self, error: Exception) -> None: + self.error = error + + def process(self, _source, *, colors, pixel_size): + raise self.error + + cases = [ + (PixelPerfectInputError("bad image"), BizCode.BAD_REQUEST), + (PixelPerfectBusyError("busy"), BizCode.TOO_MANY_REQUESTS), + (PixelPerfectUnavailableError("missing"), BizCode.MODEL_UNAVAILABLE), + ] + for error, expected_code in cases: + auth_client.app.state.pixel_perfect_tool = FailingTool(error) + response = auth_client.post( + "/tools/pixel-perfect", + files={"file": ("source.png", _png_bytes(), "image/png")}, + ) + assert response.json()["code"] == expected_code + + +def test_application_wires_the_standalone_pixel_perfect_tool(auth_client): + assert isinstance(auth_client.app.state.pixel_perfect_tool, PixelPerfectTool) + + +def test_tool_rejects_parallel_work_when_its_local_slot_is_busy(): + entered = Event() + release = Event() + + class BlockingReconstructor(_GridReconstructor): + def reconstruct(self, source, *, cols, rows, colors): + entered.set() + release.wait(timeout=1) + return super().reconstruct(source, cols=cols, rows=rows, colors=colors) + + tool = PixelPerfectTool( + detector=_GridDetector(_detected_grid()), + reconstructor=BlockingReconstructor(), + max_concurrency=1, + ) + source = _png_bytes(32, 24) + + with ThreadPoolExecutor(max_workers=1) as executor: + first = executor.submit(tool.process, source, colors=32, pixel_size=None) + assert entered.wait(timeout=1) + try: + tool.process(source, colors=32, pixel_size=None) + except PixelPerfectBusyError: + pass + else: + raise AssertionError("parallel processing must be rejected") + release.set() + assert first.result(timeout=1).png.startswith(b"\x89PNG") + + +def test_request_limit_counts_streamed_bytes_without_trusting_content_length(): + called = False + + async def app(scope, receive, send): + nonlocal called + called = True + while (await receive()).get("more_body"): + pass + + middleware = PixelPerfectRequestLimitsMiddleware(app, max_body_bytes=5) + messages = iter( + [ + {"type": "http.request", "body": b"1234", "more_body": True}, + {"type": "http.request", "body": b"56", "more_body": False}, + ] + ) + sent = [] + + async def receive(): + return next(messages) + + async def send(message): + sent.append(message) + + scope = { + "type": "http", + "method": "POST", + "path": "/tools/pixel-perfect", + "headers": [(b"content-length", b"1")], + } + asyncio.run(middleware(scope, receive, send)) + + assert called + assert sent[0]["status"] == 200 + assert b'"code":400' in sent[1]["body"] + + +def test_openapi_declares_png_success_and_json_business_errors(auth_client): + response = auth_client.get("/openapi.json").json()["paths"]["/tools/pixel-perfect"][ + "post" + ]["responses"]["200"]["content"] + + assert {"image/png", "application/json"} <= set(response) + assert "schema" in response["application/json"] From 9c01648c5f2fe4fa055191792600ed1159f139cb Mon Sep 17 00:00:00 2001 From: huyan Date: Thu, 20 Aug 2026 20:40:27 +0800 Subject: [PATCH 09/21] docs(pixel-perfect): publish file API contract The repository OpenAPI artifact must match the newly registered multipart endpoint. Export the PNG success response, business-error JSON, form parameters, and validation schema. Clients and CI now see the same explicit tool contract as the running application. --- openapi.json | 103 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 103 insertions(+) diff --git a/openapi.json b/openapi.json index 92ab9dfe..44137368 100644 --- a/openapi.json +++ b/openapi.json @@ -96,6 +96,39 @@ "title": "AssistantMessageResponse", "type": "object" }, + "Body_pixel_perfect_file_tools_pixel_perfect_post": { + "properties": { + "colors": { + "default": 32, + "maximum": 64.0, + "minimum": 2.0, + "title": "Colors", + "type": "integer" + }, + "file": { + "contentMediaType": "application/octet-stream", + "title": "File", + "type": "string" + }, + "pixel_size": { + "anyOf": [ + { + "minimum": 1.0, + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Pixel Size" + } + }, + "required": [ + "file" + ], + "title": "Body_pixel_perfect_file_tools_pixel_perfect_post", + "type": "object" + }, "Body_upload_media_media_upload_post": { "properties": { "file": { @@ -2735,6 +2768,33 @@ "title": "ToolDefinition", "type": "object" }, + "ToolErrorResponse": { + "properties": { + "code": { + "title": "Code", + "type": "integer" + }, + "data": { + "anyOf": [ + {}, + { + "type": "null" + } + ], + "title": "Data" + }, + "message": { + "title": "Message", + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "title": "ToolErrorResponse", + "type": "object" + }, "UpdateNicknameRequest": { "description": "修改昵称请求。", "properties": { @@ -4748,6 +4808,49 @@ ] } }, + "/tools/pixel-perfect": { + "post": { + "description": "显式调用本地工具;不会被任何生成流程自动触发。", + "operationId": "pixel_perfect_file_tools_pixel_perfect_post", + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/Body_pixel_perfect_file_tools_pixel_perfect_post" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ToolErrorResponse" + } + }, + "image/png": {} + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Pixel Perfect File", + "tags": [ + "tools" + ] + } + }, "/workflow-runs": { "get": { "description": "分页查询项目下的执行记录。", From da07ddd12959d9e9b392610092b21c0d45587e2f Mon Sep 17 00:00:00 2001 From: huyan Date: Fri, 21 Aug 2026 12:22:09 +0800 Subject: [PATCH 10/21] refactor(pixel-perfect): call the native extension directly The backend adapter should not spawn Rust binaries for each request. Load the PyO3 module lazily and map its typed results into existing tool contracts. Missing native code affects only this explicit endpoint, not application startup. --- .../server/pixel_perfect/factory.py | 11 +- .../windup_app/server/pixel_perfect/native.py | 179 ++++++------------ 2 files changed, 62 insertions(+), 128 deletions(-) diff --git a/backend/packages/app/src/windup_app/server/pixel_perfect/factory.py b/backend/packages/app/src/windup_app/server/pixel_perfect/factory.py index 4bb5562a..2e321e5e 100644 --- a/backend/packages/app/src/windup_app/server/pixel_perfect/factory.py +++ b/backend/packages/app/src/windup_app/server/pixel_perfect/factory.py @@ -10,16 +10,9 @@ def create_pixel_perfect_tool() -> PixelPerfectTool: - timeout_seconds = float(os.getenv("PIXEL_PERFECT_TIMEOUT_SECONDS", "30")) max_concurrency = int(os.getenv("PIXEL_PERFECT_CONCURRENCY", "1")) - detector_bin = os.getenv("PIXEL_GRID_DETECTOR_BIN", "windup-pixel-grid-detector") - reconstructor_bin = os.getenv( - "PIXEL_GRID_RECONSTRUCTOR_BIN", "windup-pixel-grid-reconstructor" - ) return PixelPerfectTool( - detector=NativeGridDetector((detector_bin,), timeout_seconds=timeout_seconds), - reconstructor=NativeGridReconstructor( - (reconstructor_bin,), timeout_seconds=timeout_seconds - ), + detector=NativeGridDetector(), + reconstructor=NativeGridReconstructor(), max_concurrency=max_concurrency, ) diff --git a/backend/packages/app/src/windup_app/server/pixel_perfect/native.py b/backend/packages/app/src/windup_app/server/pixel_perfect/native.py index 75046c96..bcd28180 100644 --- a/backend/packages/app/src/windup_app/server/pixel_perfect/native.py +++ b/backend/packages/app/src/windup_app/server/pixel_perfect/native.py @@ -1,10 +1,9 @@ -"""两个独立原生模块的进程适配器。""" +"""PyO3 原生扩展与 Python 工具编排之间的适配层。""" -from collections.abc import Sequence -import json +from collections.abc import Callable +from importlib import import_module import math -import subprocess -from threading import Thread +from typing import Protocol from windup_app.server.pixel_perfect.errors import ( PixelPerfectInputError, @@ -13,24 +12,51 @@ from windup_app.server.pixel_perfect.model import GridDetection +class PixelPerfectNativeModule(Protocol): + def detect(self, source: bytes, mode: str) -> object: ... + + def reconstruct( + self, + source: bytes, + cols: int, + rows: int, + colors: int, + ) -> object: ... + + +NativeModuleLoader = Callable[[], PixelPerfectNativeModule] + + +def _load_installed_module() -> PixelPerfectNativeModule: + return import_module("windup_pixel_perfect_native") + + +def _load_native(loader: NativeModuleLoader) -> PixelPerfectNativeModule: + try: + return loader() + except (ImportError, OSError) as error: + raise PixelPerfectUnavailableError( + "本地像素原生扩展未安装或无法加载" + ) from error + + class NativeGridDetector: - def __init__(self, command: Sequence[str], *, timeout_seconds: float) -> None: - self._command = tuple(command) - self._timeout_seconds = timeout_seconds + def __init__( + self, module_loader: NativeModuleLoader = _load_installed_module + ) -> None: + self._module_loader = module_loader def detect(self, source: bytes) -> GridDetection: - output = _run( - (*self._command, "--full"), - source, - timeout_seconds=self._timeout_seconds, - stdout_limit=64 * 1024, - ) - if len(output) > 64 * 1024: - raise PixelPerfectUnavailableError("检测器返回数据过大") + native = _load_native(self._module_loader) try: - payload = json.loads(output) - except (UnicodeDecodeError, json.JSONDecodeError) as error: - raise PixelPerfectUnavailableError("检测器返回了无效 JSON") from error + payload = native.detect(source, "full") + except ValueError as error: + raise PixelPerfectInputError( + str(error) or "原生检测器拒绝了输入" + ) from error + except Exception as error: + raise PixelPerfectUnavailableError("原生检测器调用失败") from error + expected = { "cols", "rows", @@ -68,108 +94,23 @@ def detect(self, source: bytes) -> GridDetection: class NativeGridReconstructor: - def __init__(self, command: Sequence[str], *, timeout_seconds: float) -> None: - self._command = tuple(command) - self._timeout_seconds = timeout_seconds + def __init__( + self, module_loader: NativeModuleLoader = _load_installed_module + ) -> None: + self._module_loader = module_loader def reconstruct(self, source: bytes, *, cols: int, rows: int, colors: int) -> bytes: - output = _run( - ( - *self._command, - "--cols", - str(cols), - "--rows", - str(rows), - "--colors", - str(colors), - ), - source, - timeout_seconds=self._timeout_seconds, - stdout_limit=32 * 1024 * 1024, - ) + native = _load_native(self._module_loader) + try: + output = native.reconstruct(source, cols, rows, colors) + except ValueError as error: + raise PixelPerfectInputError( + str(error) or "原生重建器拒绝了输入" + ) from error + except Exception as error: + raise PixelPerfectUnavailableError("原生重建器调用失败") from error + if not isinstance(output, bytes): + raise PixelPerfectUnavailableError("重建器返回类型不符合约定") if len(output) > 32 * 1024 * 1024: raise PixelPerfectUnavailableError("重建器返回数据过大") return output - - -def _run( - command: Sequence[str], - source: bytes, - *, - timeout_seconds: float, - stdout_limit: int, - stderr_limit: int = 64 * 1024, -) -> bytes: - if not command or timeout_seconds <= 0 or min(stdout_limit, stderr_limit) < 1: - raise ValueError("native command and positive timeout are required") - try: - process = subprocess.Popen( - command, - stdin=subprocess.PIPE, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) - except (FileNotFoundError, OSError) as error: - raise PixelPerfectUnavailableError("本地像素工具未安装或无法启动") from error - - stdout = bytearray() - stderr = bytearray() - overflow = [] - - def read_bounded(stream, target: bytearray, limit: int, name: str) -> None: - while chunk := stream.read(64 * 1024): - remaining = limit + 1 - len(target) - target.extend(chunk[:remaining]) - if len(target) > limit: - overflow.append(name) - process.kill() - break - stream.close() - - def write_input() -> None: - try: - process.stdin.write(source) - except (BrokenPipeError, OSError): - pass - finally: - process.stdin.close() - - threads = [ - Thread(target=write_input, daemon=True), - Thread( - target=read_bounded, - args=(process.stdout, stdout, stdout_limit, "stdout"), - daemon=True, - ), - Thread( - target=read_bounded, - args=(process.stderr, stderr, stderr_limit, "stderr"), - daemon=True, - ), - ] - for thread in threads: - thread.start() - try: - process.wait(timeout=timeout_seconds) - except subprocess.TimeoutExpired as error: - process.kill() - process.wait() - raise PixelPerfectUnavailableError("本地像素工具处理超时") from error - finally: - for thread in threads: - thread.join() - - if overflow: - raise PixelPerfectUnavailableError(f"本地像素工具 {overflow[0]} 超过资源上限") - if process.returncode < 0: - raise PixelPerfectUnavailableError( - f"本地像素工具被信号 {-process.returncode} 终止" - ) - if process.returncode == 1: - detail = stderr.decode("utf-8", errors="replace").strip()[:500] - raise PixelPerfectInputError(detail or "本地像素工具拒绝了输入") - if process.returncode != 0: - raise PixelPerfectUnavailableError( - f"本地像素工具异常退出(code={process.returncode})" - ) - return bytes(stdout) From 2182cda48da8e9d5055f8ee201f61e75f598a1c6 Mon Sep 17 00:00:00 2001 From: huyan Date: Fri, 21 Aug 2026 12:22:09 +0800 Subject: [PATCH 11/21] test(pixel-perfect): cover the PyO3 adapter contract The backend needs a stable boundary for detector and reconstructor extension calls. Replace process-protocol cases with direct-call, input-error, and lazy-load coverage. Tests now exercise the selected native-extension integration shape. --- backend/tests/test_pixel_perfect_api.py | 98 ++++++++++--------------- 1 file changed, 40 insertions(+), 58 deletions(-) diff --git a/backend/tests/test_pixel_perfect_api.py b/backend/tests/test_pixel_perfect_api.py index d664da97..55ee404c 100644 --- a/backend/tests/test_pixel_perfect_api.py +++ b/backend/tests/test_pixel_perfect_api.py @@ -2,10 +2,7 @@ import asyncio from io import BytesIO -import json -import signal import struct -import sys import zlib from concurrent.futures import ThreadPoolExecutor from threading import Event @@ -270,7 +267,7 @@ def reconstruct(self, source, *, cols, rows, colors): raise AssertionError("invalid reconstruction must be rejected") -def test_native_detector_reads_stdin_and_parses_the_six_field_contract(): +def test_native_detector_calls_the_extension_and_parses_the_six_field_contract(): payload = { "cols": 8, "rows": 6, @@ -279,84 +276,69 @@ def test_native_detector_reads_stdin_and_parses_the_six_field_contract(): "consensus": "arbitrated", "confidence": "medium", } - code = ( - "import json,sys; sys.stdin.buffer.read(); " - f"print(json.dumps({json.dumps(payload)}))" - ) - detector = NativeGridDetector((sys.executable, "-c", code), timeout_seconds=1) + calls = [] + + class NativeModule: + @staticmethod + def detect(source: bytes, mode: str): + calls.append((source, mode)) + return payload - result = detector.detect(_png_bytes(32, 24)) + detector = NativeGridDetector(module_loader=lambda: NativeModule) + source = _png_bytes(32, 24) + + result = detector.detect(source) assert result == GridDetection(**payload) + assert calls == [(source, "full")] -def test_native_reconstructor_passes_only_explicit_grid_arguments(): +def test_native_reconstructor_passes_only_the_explicit_grid_contract(): output = _png_bytes(8, 6) - code = ( - "import sys; data=sys.stdin.buffer.read(); " - "expected=['--cols','8','--rows','6','--colors','16']; " - "sys.exit(2) if sys.argv[1:] != expected else sys.stdout.buffer.write(data)" - ) - reconstructor = NativeGridReconstructor( - (sys.executable, "-c", code), timeout_seconds=1 - ) + calls = [] + + class NativeModule: + @staticmethod + def reconstruct(source: bytes, cols: int, rows: int, colors: int): + calls.append((source, cols, rows, colors)) + return source + + reconstructor = NativeGridReconstructor(module_loader=lambda: NativeModule) result = reconstructor.reconstruct(output, cols=8, rows=6, colors=16) assert result == output + assert calls == [(output, 8, 6, 16)] -def test_native_process_timeout_is_reported_as_tool_unavailable(): - detector = NativeGridDetector( - (sys.executable, "-c", "import time; time.sleep(1)"), - timeout_seconds=0.01, - ) +def test_native_extension_value_errors_are_reported_as_bad_input(): + class NativeModule: + @staticmethod + def detect(_source: bytes, _mode: str): + raise ValueError("invalid image") + + detector = NativeGridDetector(module_loader=lambda: NativeModule) try: detector.detect(_png_bytes(32, 24)) - except PixelPerfectUnavailableError as error: - assert "超时" in str(error) + except PixelPerfectInputError as error: + assert "invalid image" in str(error) else: - raise AssertionError("native timeout must be reported") + raise AssertionError("native input errors must preserve their category") -def test_native_process_output_is_bounded_while_the_child_is_running(): - detector = NativeGridDetector( - ( - sys.executable, - "-c", - "import sys; sys.stdin.buffer.read(); sys.stdout.buffer.write(b'x'*70000)", - ), - timeout_seconds=1, - ) +def test_missing_native_extension_is_reported_as_tool_unavailable(): + def missing_module(): + raise ModuleNotFoundError("windup_pixel_perfect_native") - try: - detector.detect(_png_bytes(32, 24)) - except PixelPerfectUnavailableError as error: - assert "stdout" in str(error) - else: - raise AssertionError("oversized native output must be rejected") - - -def test_native_signal_exit_is_tool_unavailable_not_bad_input(): - detector = NativeGridDetector( - ( - sys.executable, - "-c", - ( - "import os,signal,sys; sys.stdin.buffer.read(); " - "os.kill(os.getpid(), signal.SIGTERM)" - ), - ), - timeout_seconds=1, - ) + detector = NativeGridDetector(module_loader=missing_module) try: detector.detect(_png_bytes(32, 24)) except PixelPerfectUnavailableError as error: - assert str(signal.SIGTERM) in str(error) + assert "未安装" in str(error) else: - raise AssertionError("signal exit must be unavailable") + raise AssertionError("missing native extensions must not break app startup") def test_endpoint_maps_local_tool_failures_to_stable_business_codes(auth_client): From 56b825ced5a4b194210d4fe0ccb3258a9a3a4c8e Mon Sep 17 00:00:00 2001 From: huyan Date: Fri, 21 Aug 2026 12:53:26 +0800 Subject: [PATCH 12/21] build(pixel-perfect): define the PyO3 extension package The backend adapter needs a native module that can link both independent Rust crates. Add the locked Cargo and maturin package metadata for an abi3 Python 3.12 extension. This gives the binding a reproducible package boundary without changing either algorithm. --- .../pixel-perfect/bindings/python/Cargo.lock | 441 ++++++++++++++++++ .../pixel-perfect/bindings/python/Cargo.toml | 19 + .../bindings/python/pyproject.toml | 12 + 3 files changed, 472 insertions(+) create mode 100644 native/pixel-perfect/bindings/python/Cargo.lock create mode 100644 native/pixel-perfect/bindings/python/Cargo.toml create mode 100644 native/pixel-perfect/bindings/python/pyproject.toml diff --git a/native/pixel-perfect/bindings/python/Cargo.lock b/native/pixel-perfect/bindings/python/Cargo.lock new file mode 100644 index 00000000..48d53743 --- /dev/null +++ b/native/pixel-perfect/bindings/python/Cargo.lock @@ -0,0 +1,441 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[package]] +name = "either" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", + "png", + "zune-core", + "zune-jpeg", +] + +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "primal-check" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc0d895b311e3af9902528fbb8f928688abbd95872819320517cc24ca6b2bd08" +dependencies = [ + "num-integer", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + +[[package]] +name = "pyo3" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ba0117f4212101ee6544044dae45abe1083d30ce7b29c4b5cbdfa2354e07383" +dependencies = [ + "indoc", + "libc", + "memoffset", + "once_cell", + "portable-atomic", + "pyo3-build-config", + "pyo3-ffi", + "pyo3-macros", + "unindent", +] + +[[package]] +name = "pyo3-build-config" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fc6ddaf24947d12a9aa31ac65431fb1b851b8f4365426e182901eabfb87df5f" +dependencies = [ + "target-lexicon", +] + +[[package]] +name = "pyo3-ffi" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "025474d3928738efb38ac36d4744a74a400c901c7596199e20e45d98eb194105" +dependencies = [ + "libc", + "pyo3-build-config", +] + +[[package]] +name = "pyo3-macros" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e64eb489f22fe1c95911b77c44cc41e7c19f3082fc81cce90f657cdc42ffded" +dependencies = [ + "proc-macro2", + "pyo3-macros-backend", + "quote", + "syn", +] + +[[package]] +name = "pyo3-macros-backend" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "100246c0ecf400b475341b8455a9213344569af29a3c841d29270e53102e0fcf" +dependencies = [ + "heck", + "proc-macro2", + "pyo3-build-config", + "quote", + "syn", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "rustfft" +version = "6.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21db5f9893e91f41798c88680037dba611ca6674703c1a18601b01a72c8adb89" +dependencies = [ + "num-complex", + "num-integer", + "num-traits", + "primal-check", + "strength_reduce", + "transpose", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "strength_reduce" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe895eb47f22e2ddd4dabc02bce419d2e643c8e3b585c78158b349195bc24d82" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "target-lexicon" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" + +[[package]] +name = "transpose" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad61aed86bc3faea4300c7aee358b4c6d0c8d6ccc36524c96e4c92ccf26e77e" +dependencies = [ + "num-integer", + "strength_reduce", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unindent" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7264e107f553ccae879d21fbea1d6724ac785e8c3bfc762137959b5802826ef3" + +[[package]] +name = "windup-pixel-grid-detector" +version = "0.1.0" +dependencies = [ + "image", + "rayon", + "rustfft", +] + +[[package]] +name = "windup-pixel-grid-reconstructor" +version = "0.1.0" +dependencies = [ + "image", + "rayon", +] + +[[package]] +name = "windup-pixel-perfect-native" +version = "0.1.0" +dependencies = [ + "pyo3", + "windup-pixel-grid-detector", + "windup-pixel-grid-reconstructor", +] + +[[package]] +name = "zune-core" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] diff --git a/native/pixel-perfect/bindings/python/Cargo.toml b/native/pixel-perfect/bindings/python/Cargo.toml new file mode 100644 index 00000000..7309c1a8 --- /dev/null +++ b/native/pixel-perfect/bindings/python/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "windup-pixel-perfect-native" +version = "0.1.0" +edition = "2021" +license = "MIT" +publish = false + +[lib] +name = "windup_pixel_perfect_native" +crate-type = ["cdylib"] + +[dependencies] +pyo3 = { version = "0.26", features = ["abi3-py312", "extension-module"] } +windup-pixel-grid-detector = { path = "../../crates/detector" } +windup-pixel-grid-reconstructor = { path = "../../crates/reconstructor" } + +[profile.release] +lto = true +codegen-units = 1 diff --git a/native/pixel-perfect/bindings/python/pyproject.toml b/native/pixel-perfect/bindings/python/pyproject.toml new file mode 100644 index 00000000..5ece9db4 --- /dev/null +++ b/native/pixel-perfect/bindings/python/pyproject.toml @@ -0,0 +1,12 @@ +[build-system] +requires = ["maturin==1.14.1"] +build-backend = "maturin" + +[project] +name = "windup-pixel-perfect-native" +version = "0.1.0" +requires-python = ">=3.12" + +[tool.maturin] +bindings = "pyo3" +module-name = "windup_pixel_perfect_native" From be6e23c5c3c31d201b4566fa25720cdfc1064a93 Mon Sep 17 00:00:00 2001 From: huyan Date: Fri, 21 Aug 2026 12:53:35 +0800 Subject: [PATCH 13/21] feat(pixel-perfect): expose Rust algorithms through PyO3 The Python adapter should call the independent detector and reconstructor without subprocess overhead. Expose byte-oriented detect and reconstruct functions while releasing the interpreter during Rust work. The extension preserves the existing algorithm inputs and maps invalid native input to ValueError. --- .../pixel-perfect/bindings/python/src/lib.rs | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 native/pixel-perfect/bindings/python/src/lib.rs diff --git a/native/pixel-perfect/bindings/python/src/lib.rs b/native/pixel-perfect/bindings/python/src/lib.rs new file mode 100644 index 00000000..554d56a9 --- /dev/null +++ b/native/pixel-perfect/bindings/python/src/lib.rs @@ -0,0 +1,55 @@ +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; +use pyo3::types::{PyBytes, PyDict, PyModule}; +use windup_pixel_grid_detector::{detect_bytes, DetectorMode}; +use windup_pixel_grid_reconstructor::reconstruct_bytes; + +#[pyfunction] +#[pyo3(signature = (source, mode = "full"))] +fn detect<'py>( + py: Python<'py>, + source: &Bound<'py, PyBytes>, + mode: &str, +) -> PyResult> { + let detector_mode = match mode { + "full" => DetectorMode::Full, + "fast" => DetectorMode::Fast, + _ => return Err(PyValueError::new_err("mode must be 'full' or 'fast'")), + }; + // The Python buffer cannot be borrowed while the interpreter is detached. + let input = source.as_bytes().to_vec(); + let result = py + .detach(move || detect_bytes(&input, detector_mode)) + .map_err(|error| PyValueError::new_err(error.to_string()))?; + + let payload = PyDict::new(py); + payload.set_item("cols", result.cols)?; + payload.set_item("rows", result.rows)?; + payload.set_item("step_x", result.step_x)?; + payload.set_item("step_y", result.step_y)?; + payload.set_item("consensus", result.consensus)?; + payload.set_item("confidence", result.confidence)?; + Ok(payload) +} + +#[pyfunction] +fn reconstruct<'py>( + py: Python<'py>, + source: &Bound<'py, PyBytes>, + cols: usize, + rows: usize, + colors: usize, +) -> PyResult> { + let input = source.as_bytes().to_vec(); + let result = py + .detach(move || reconstruct_bytes(&input, cols, rows, colors)) + .map_err(|error| PyValueError::new_err(error.to_string()))?; + Ok(PyBytes::new(py, &result.png)) +} + +#[pymodule] +fn windup_pixel_perfect_native(module: &Bound<'_, PyModule>) -> PyResult<()> { + module.add_function(wrap_pyfunction!(detect, module)?)?; + module.add_function(wrap_pyfunction!(reconstruct, module)?)?; + Ok(()) +} From ef58d29e0283165a8c98a79b46455c1a0ab478e0 Mon Sep 17 00:00:00 2001 From: huyan Date: Fri, 21 Aug 2026 12:53:45 +0800 Subject: [PATCH 14/21] test(pixel-perfect): verify the native binding contract The extension boundary needs direct coverage apart from the FastAPI adapter tests. Exercise detector metadata, explicit-grid reconstruction, and invalid-input error mapping. This protects the Python-facing ABI without changing detector or reconstructor behavior. --- .../bindings/python/tests/test_binding.py | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 native/pixel-perfect/bindings/python/tests/test_binding.py diff --git a/native/pixel-perfect/bindings/python/tests/test_binding.py b/native/pixel-perfect/bindings/python/tests/test_binding.py new file mode 100644 index 00000000..be13db1c --- /dev/null +++ b/native/pixel-perfect/bindings/python/tests/test_binding.py @@ -0,0 +1,52 @@ +from io import BytesIO + +from PIL import Image +import pytest + +import windup_pixel_perfect_native as native + + +def _png_bytes(width: int = 64, height: int = 64) -> bytes: + logical = Image.new("RGBA", (16, 16)) + palette = [ + (30, 24, 20, 255), + (210, 130, 70, 255), + (235, 225, 200, 255), + (70, 120, 135, 255), + ] + for y in range(16): + for x in range(16): + logical.putpixel((x, y), palette[(x * 7 + y * 11) % len(palette)]) + image = logical.resize((width, height), Image.Resampling.NEAREST) + output = BytesIO() + image.save(output, "PNG") + return output.getvalue() + + +def test_binding_detects_the_six_field_grid_contract() -> None: + result = native.detect(_png_bytes(), "full") + + assert set(result) == { + "cols", + "rows", + "step_x", + "step_y", + "consensus", + "confidence", + } + assert 15 <= result["cols"] <= 17 + assert 15 <= result["rows"] <= 17 + assert result["confidence"] in {"high", "medium", "low"} + + +def test_binding_reconstructs_an_explicit_grid_to_png_bytes() -> None: + output = native.reconstruct(_png_bytes(), 16, 16, 4) + image = Image.open(BytesIO(output)) + + assert image.format == "PNG" + assert image.size == (16, 16) + + +def test_binding_maps_invalid_input_to_value_error() -> None: + with pytest.raises(ValueError, match="PNG or JPEG"): + native.detect(b"not-an-image", "full") From 2a6cf23e081acc0840cbf0f470d95c1034f3ea28 Mon Sep 17 00:00:00 2001 From: huyan Date: Fri, 21 Aug 2026 12:53:53 +0800 Subject: [PATCH 15/21] docs(pixel-perfect): document native binding usage Backend contributors need a short reference for building and calling the extension locally. Document the two byte-oriented functions, the maturin command, and error ownership. This keeps the native boundary discoverable without adding generation-pipeline guidance. --- native/pixel-perfect/bindings/python/README.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 native/pixel-perfect/bindings/python/README.md diff --git a/native/pixel-perfect/bindings/python/README.md b/native/pixel-perfect/bindings/python/README.md new file mode 100644 index 00000000..ee4ccc4b --- /dev/null +++ b/native/pixel-perfect/bindings/python/README.md @@ -0,0 +1,16 @@ +# Python binding + +PyO3 binding for the independent detector and reconstructor crates. It exposes only two byte-oriented functions: + +```python +detect(source, mode="full") +reconstruct(source, cols, rows, colors) +``` + +For local development, install the extension into the active backend environment: + +```bash +uv tool run --from maturin==1.14.1 maturin develop --release --locked +``` + +The Rust computation detaches from the Python interpreter. Input validation errors are raised as `ValueError`; loading and output validation remain the responsibility of the Python adapter. From 94e955c4e3a023038f569c23a61bf6a61365b55f Mon Sep 17 00:00:00 2001 From: huyan Date: Fri, 21 Aug 2026 12:54:05 +0800 Subject: [PATCH 16/21] chore(pixel-perfect): ignore native build output Local maturin and Cargo verification creates target artifacts beside the binding crate. Exclude the crate target directory from version control. This keeps local native builds from polluting the adapter branch. --- native/pixel-perfect/bindings/python/.gitignore | 1 + 1 file changed, 1 insertion(+) create mode 100644 native/pixel-perfect/bindings/python/.gitignore diff --git a/native/pixel-perfect/bindings/python/.gitignore b/native/pixel-perfect/bindings/python/.gitignore new file mode 100644 index 00000000..b83d2226 --- /dev/null +++ b/native/pixel-perfect/bindings/python/.gitignore @@ -0,0 +1 @@ +/target/ From cbbae0b4cf11f277f51273a922a663d843c368d6 Mon Sep 17 00:00:00 2001 From: huyan Date: Fri, 21 Aug 2026 12:54:15 +0800 Subject: [PATCH 17/21] build(pixel-perfect): install the native wheel in containers The backend and worker images need the PyO3 module while keeping Rust tooling out of runtime. Build a locked wheel in a dedicated stage and install it through the repository-root build context. Both services now share the packaged extension and retain only required native licenses at runtime. --- .dockerignore | 12 ++++++++++ backend/Dockerfile | 59 +++++++++++++++++++++++++++++++--------------- docker-compose.yml | 8 +++---- 3 files changed, 56 insertions(+), 23 deletions(-) create mode 100644 .dockerignore diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..26bf0b4d --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +** +!backend/ +!backend/** +!native/ +!native/pixel-perfect/ +!native/pixel-perfect/** + +backend/.venv/ +backend/.pytest_cache/ +backend/.ruff_cache/ +backend/**/__pycache__/ +native/pixel-perfect/**/target/ diff --git a/backend/Dockerfile b/backend/Dockerfile index 31904fd8..4d81f49e 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,14 +1,34 @@ # ── 后端 Dockerfile ────────────────────────────────────────────────── # 多阶段构建:builder 装依赖 → runtime 只拷贝产物,镜像更小 -# ── 阶段 0: 构建两个独立的本地像素工具 ── -FROM rust:1.89-slim AS pixel-tools-builder +# ── 阶段 0: 构建 PyO3 原生扩展 wheel ── +FROM rust:1.89-slim AS rust-toolchain -WORKDIR /build -COPY native/pixel_grid_detector/ pixel_grid_detector/ -COPY native/pixel_grid_reconstructor/ pixel_grid_reconstructor/ -RUN cargo build --release --locked --manifest-path pixel_grid_detector/Cargo.toml \ - && cargo build --release --locked --manifest-path pixel_grid_reconstructor/Cargo.toml +FROM python:3.12-slim AS pixel-native-builder + +COPY --from=rust-toolchain /usr/local/cargo /usr/local/cargo +COPY --from=rust-toolchain /usr/local/rustup /usr/local/rustup +COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv + +ENV PATH="/usr/local/cargo/bin:$PATH" \ + CARGO_HOME=/usr/local/cargo \ + RUSTUP_HOME=/usr/local/rustup \ + UV_DEFAULT_INDEX=https://mirrors.aliyun.com/pypi/simple/ \ + UV_HTTP_TIMEOUT=180 + +RUN apt-get update \ + && apt-get install -y --no-install-recommends gcc libc6-dev \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /build/native/pixel-perfect +COPY native/pixel-perfect/crates/detector/ crates/detector/ +COPY native/pixel-perfect/crates/reconstructor/ crates/reconstructor/ +COPY native/pixel-perfect/bindings/python/ bindings/python/ +RUN uv tool run --from maturin==1.14.1 maturin build \ + --release \ + --locked \ + --manifest-path bindings/python/Cargo.toml \ + --out /wheels # ── 阶段 1: 构建 Python 环境 ── FROM python:3.12-slim AS builder @@ -26,19 +46,24 @@ ENV UV_DEFAULT_INDEX=https://mirrors.aliyun.com/pypi/simple/ \ WORKDIR /app # 先拷贝依赖定义,利用 Docker layer cache -COPY pyproject.toml uv.lock ./ -COPY packages/common/pyproject.toml packages/common/ -COPY packages/framework/pyproject.toml packages/framework/ -COPY packages/ai_engine/pyproject.toml packages/ai_engine/ -COPY packages/app/pyproject.toml packages/app/ +COPY backend/pyproject.toml backend/uv.lock ./ +COPY backend/packages/common/pyproject.toml packages/common/ +COPY backend/packages/framework/pyproject.toml packages/framework/ +COPY backend/packages/ai_engine/pyproject.toml packages/ai_engine/ +COPY backend/packages/app/pyproject.toml packages/app/ # 安装依赖(不含 dev 依赖) RUN uv sync --frozen --no-dev --no-install-workspace # 拷贝源码并安装 -COPY packages/ packages/ +COPY backend/packages/ packages/ RUN uv sync --frozen --no-dev +# 原生扩展是显式工具的可选源码依赖,由镜像在构建期安装;缺失时应用仍可启动。 +COPY --from=pixel-native-builder /wheels /wheels +RUN uv pip install --python /app/.venv/bin/python /wheels/*.whl \ + && /app/.venv/bin/python -c "import windup_pixel_perfect_native" + # ── 阶段 2: 运行时 ── FROM python:3.12-slim AS runtime @@ -47,10 +72,8 @@ WORKDIR /app # 从 builder 拷贝虚拟环境和包 COPY --from=builder /app/.venv /app/.venv COPY --from=builder /app/packages /app/packages -COPY --from=pixel-tools-builder /build/pixel_grid_detector/target/release/windup-pixel-grid-detector /usr/local/bin/windup-pixel-grid-detector -COPY --from=pixel-tools-builder /build/pixel_grid_reconstructor/target/release/windup-pixel-grid-reconstructor /usr/local/bin/windup-pixel-grid-reconstructor -COPY native/pixel_grid_detector/LICENSE /licenses/pixel-grid-detector/LICENSE -COPY native/pixel_grid_reconstructor/LICENSE /licenses/pixel-grid-reconstructor/LICENSE +COPY native/pixel-perfect/crates/detector/LICENSE /licenses/pixel-grid-detector/LICENSE +COPY native/pixel-perfect/crates/reconstructor/LICENSE /licenses/pixel-grid-reconstructor/LICENSE # 把 venv/bin 加入 PATH ENV PATH="/app/.venv/bin:$PATH" @@ -59,8 +82,6 @@ ENV PATH="/app/.venv/bin:$PATH" ENV WINDUP_HOST=0.0.0.0 ENV WINDUP_PORT=8000 ENV WINDUP_RELOAD=false -ENV PIXEL_GRID_DETECTOR_BIN=/usr/local/bin/windup-pixel-grid-detector -ENV PIXEL_GRID_RECONSTRUCTOR_BIN=/usr/local/bin/windup-pixel-grid-reconstructor EXPOSE 8000 diff --git a/docker-compose.yml b/docker-compose.yml index 91371cf6..7656cf1b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -46,8 +46,8 @@ services: # ── 后端 API 服务 ── backend: build: - context: ./backend - dockerfile: Dockerfile + context: . + dockerfile: backend/Dockerfile container_name: windup-backend restart: unless-stopped depends_on: @@ -70,8 +70,8 @@ services: # ── Worker(MQ 消费:邮件 + 生成调度) ── worker: build: - context: ./backend - dockerfile: Dockerfile + context: . + dockerfile: backend/Dockerfile container_name: windup-worker restart: unless-stopped depends_on: From cfde2b995d6422d9c023fde10f2d56ad2079f6b2 Mon Sep 17 00:00:00 2001 From: huyan Date: Fri, 21 Aug 2026 16:30:52 +0800 Subject: [PATCH 18/21] fix(pixel-perfect): preserve oversized upload errors Starlette converts multipart receive failures into its own HTTP 400 response. Suppress that parser response after the byte limit trips and emit the existing business envelope. Oversized uploads now retain the endpoint contract without weakening the pre-spool limit. --- .../web/middleware/pixel_perfect_limits.py | 32 +++++++++++++------ 1 file changed, 22 insertions(+), 10 deletions(-) diff --git a/backend/packages/app/src/windup_app/web/middleware/pixel_perfect_limits.py b/backend/packages/app/src/windup_app/web/middleware/pixel_perfect_limits.py index 34f55463..21bde619 100644 --- a/backend/packages/app/src/windup_app/web/middleware/pixel_perfect_limits.py +++ b/backend/packages/app/src/windup_app/web/middleware/pixel_perfect_limits.py @@ -47,26 +47,38 @@ async def __call__(self, scope, receive, send) -> None: self._active += 1 received = 0 + body_too_large = False async def bounded_receive(): - nonlocal received + nonlocal body_too_large, received message = await receive() if message["type"] == "http.request": received += len(message.get("body", b"")) if received > self.max_body_bytes: + body_too_large = True raise _BodyTooLarge return message + async def bounded_send(message): + # Starlette 把 multipart 读取异常改写成自己的 400;先压住该响应, + # 再由本中间件返回项目约定的业务错误包络。 + if not body_too_large: + await send(message) + try: - await self.app(scope, bounded_receive, send) - except _BodyTooLarge: - await self._reject( - scope, - receive, - send, - "请求体不能超过 11 MB", - BizCode.BAD_REQUEST, - ) + try: + await self.app(scope, bounded_receive, bounded_send) + except _BodyTooLarge: + body_too_large = True + + if body_too_large: + await self._reject( + scope, + receive, + send, + "请求体不能超过 11 MB", + BizCode.BAD_REQUEST, + ) finally: async with self._lock: self._active -= 1 From 1e4cd1d7826a672f2249ce799fa8dccc4c6c3124 Mon Sep 17 00:00:00 2001 From: huyan Date: Fri, 21 Aug 2026 16:31:04 +0800 Subject: [PATCH 19/21] test(pixel-perfect): cover oversized multipart uploads The direct middleware test did not exercise Starlette multipart error handling. Send an upload above the ASGI limit through the authenticated TestClient endpoint. The regression now guards the HTTP 200 business-error envelope at the real API boundary. --- backend/tests/test_pixel_perfect_api.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/backend/tests/test_pixel_perfect_api.py b/backend/tests/test_pixel_perfect_api.py index 55ee404c..2447b8da 100644 --- a/backend/tests/test_pixel_perfect_api.py +++ b/backend/tests/test_pixel_perfect_api.py @@ -117,6 +117,28 @@ def test_pixel_perfect_endpoint_bounds_the_uploaded_bytes(auth_client): assert tool.calls == [] +def test_pixel_perfect_endpoint_wraps_oversized_multipart_as_business_error( + auth_client, +): + response = auth_client.post( + "/tools/pixel-perfect", + files={ + "file": ( + "source.png", + bytes(11 * 1024 * 1024 + 1), + "image/png", + ) + }, + ) + + assert response.status_code == 200 + assert response.json() == { + "code": BizCode.BAD_REQUEST, + "message": "请求体不能超过 11 MB", + "data": None, + } + + def test_pixel_perfect_metadata_headers_are_visible_to_the_browser(auth_client): auth_client.app.state.pixel_perfect_tool = _RecordingTool() From bc337f3946991406e93831ee41e658622da8abcb Mon Sep 17 00:00:00 2001 From: huyan Date: Fri, 21 Aug 2026 16:31:29 +0800 Subject: [PATCH 20/21] refactor(pixel-perfect): centralize concurrency settings The same environment variable was parsed independently by the tool factory and middleware wiring. Define one framework settings object and inject its validated value from the composition root. The feature module stays framework-independent while both resource guards share one setting. --- .../app/src/windup_app/bootstrap/app.py | 7 +++++-- .../windup_app/server/pixel_perfect/factory.py | 5 +---- .../windup_framework/config/pixel_perfect.py | 18 ++++++++++++++++++ 3 files changed, 24 insertions(+), 6 deletions(-) create mode 100644 backend/packages/framework/src/windup_framework/config/pixel_perfect.py diff --git a/backend/packages/app/src/windup_app/bootstrap/app.py b/backend/packages/app/src/windup_app/bootstrap/app.py index 5cdc379c..ecdb60ba 100644 --- a/backend/packages/app/src/windup_app/bootstrap/app.py +++ b/backend/packages/app/src/windup_app/bootstrap/app.py @@ -12,6 +12,7 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware +from windup_framework.config.pixel_perfect import settings as pixel_perfect_settings from windup_framework.db import Base, engine # 模型导入:触发 Base.metadata 注册,确保 create_all 能发现所有表 @@ -107,7 +108,9 @@ def create_app() -> FastAPI: app = FastAPI(title="windup", version="0.1.0", lifespan=_lifespan) app.state.mq_publisher = MqPublisher() app.state.chat_model_factory = create_chat_model - app.state.pixel_perfect_tool = create_pixel_perfect_tool() + app.state.pixel_perfect_tool = create_pixel_perfect_tool( + max_concurrency=pixel_perfect_settings.concurrency + ) # 起名器在 composition root 注入,避免 web→character.service 碰到 ai_engine。 # LangChainCharacterNamer 构造期不创建 ChatOpenAI;缺 AI_API_KEY 时应用仍能启动。 # 测试若已注入假 namer,不要覆盖。 @@ -122,7 +125,7 @@ def health() -> dict[str, str]: app.add_middleware(AuthMiddleware) app.add_middleware( PixelPerfectRequestLimitsMiddleware, - max_concurrency=int(os.getenv("PIXEL_PERFECT_CONCURRENCY", "1")), + max_concurrency=pixel_perfect_settings.concurrency, ) app.add_middleware( CORSMiddleware, diff --git a/backend/packages/app/src/windup_app/server/pixel_perfect/factory.py b/backend/packages/app/src/windup_app/server/pixel_perfect/factory.py index 2e321e5e..6828cad8 100644 --- a/backend/packages/app/src/windup_app/server/pixel_perfect/factory.py +++ b/backend/packages/app/src/windup_app/server/pixel_perfect/factory.py @@ -1,7 +1,5 @@ """完美像素工具在应用 composition root 使用的本地装配。""" -import os - from windup_app.server.pixel_perfect.native import ( NativeGridDetector, NativeGridReconstructor, @@ -9,8 +7,7 @@ from windup_app.server.pixel_perfect.service import PixelPerfectTool -def create_pixel_perfect_tool() -> PixelPerfectTool: - max_concurrency = int(os.getenv("PIXEL_PERFECT_CONCURRENCY", "1")) +def create_pixel_perfect_tool(*, max_concurrency: int) -> PixelPerfectTool: return PixelPerfectTool( detector=NativeGridDetector(), reconstructor=NativeGridReconstructor(), diff --git a/backend/packages/framework/src/windup_framework/config/pixel_perfect.py b/backend/packages/framework/src/windup_framework/config/pixel_perfect.py new file mode 100644 index 00000000..b311db7c --- /dev/null +++ b/backend/packages/framework/src/windup_framework/config/pixel_perfect.py @@ -0,0 +1,18 @@ +"""独立完美像素工具的本地资源配置。""" + +from pydantic import Field +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class PixelPerfectSettings(BaseSettings): + model_config = SettingsConfigDict( + env_prefix="PIXEL_PERFECT_", + env_file=("../.env", ".env"), + env_file_encoding="utf-8", + extra="ignore", + ) + + concurrency: int = Field(default=1, ge=1) + + +settings = PixelPerfectSettings() From 6560dc80ac9491ca92f40d2341f0502eb29acdb0 Mon Sep 17 00:00:00 2001 From: huyan Date: Fri, 21 Aug 2026 16:31:41 +0800 Subject: [PATCH 21/21] docs(pixel-perfect): publish the concurrency setting Deployers need to discover the resource limit without reading application wiring. Add the validated PIXEL_PERFECT_CONCURRENCY key to the environment template. Local and container deployments can now tune the explicit tool from the documented config surface. --- .env.example | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.env.example b/.env.example index a5e20f75..70ceb157 100644 --- a/.env.example +++ b/.env.example @@ -67,6 +67,10 @@ WINDUP_CORS_ORIGINS= # 跨域正则匹配(默认关闭;如需 Vercel 预览域名,请限制到自己的项目名前缀) WINDUP_CORS_ORIGIN_REGEX= +# ── 独立完美像素工具 ── +# 单个进程同时处理的图片数;Rust 运算会释放 GIL,但仍受本机 CPU / 内存约束。 +PIXEL_PERFECT_CONCURRENCY=1 + # ── MQ(Redis Stream 轻量消息队列) ── # 本地/Compose 须同时起 web + worker,否则生成任务会一直 PENDING、邮件不会发出。 # docker compose up -d backend worker