Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -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/
50 changes: 43 additions & 7 deletions backend/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,7 +1,36 @@
# ── 后端 Dockerfile ──────────────────────────────────────────────────
# 多阶段构建:builder 装依赖 → runtime 只拷贝产物,镜像更小

# ── 阶段 1: 构建 ──
# ── 阶段 0: 构建 PyO3 原生扩展 wheel ──
FROM rust:1.89-slim AS rust-toolchain

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

# 安装 uv(比 pip 快 10x)
Expand All @@ -17,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

Expand All @@ -38,6 +72,8 @@ WORKDIR /app
# 从 builder 拷贝虚拟环境和包
COPY --from=builder /app/.venv /app/.venv
COPY --from=builder /app/packages /app/packages
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"
Expand Down
1 change: 1 addition & 0 deletions backend/packages/app/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ dependencies = [
"pydantic[email]>=2.7",
"sqlalchemy>=2.0",
"python-multipart>=0.0.9",
"pillow>=10.4",
]

[project.scripts]
Expand Down
19 changes: 19 additions & 0 deletions backend/packages/app/src/windup_app/bootstrap/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,不要覆盖。
Expand All @@ -114,19 +120,32 @@ 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(),
allow_origin_regex=_cors_origin_regex(),
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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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",
]
17 changes: 17 additions & 0 deletions backend/packages/app/src/windup_app/server/pixel_perfect/errors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
"""完美像素工具可以映射为稳定 API 结果的失败类型。"""


class PixelPerfectError(Exception):
"""本地工具可预期失败的基类。"""


class PixelPerfectBusyError(PixelPerfectError):
pass


Comment on lines +4 to +11

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

这里新定义的异常应该收缩到common层级下

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

class PixelPerfectInputError(PixelPerfectError):
pass


class PixelPerfectUnavailableError(PixelPerfectError):
pass
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
"""完美像素工具在应用 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:
max_concurrency = int(os.getenv("PIXEL_PERFECT_CONCURRENCY", "1"))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

配置选项应该收缩到framework.conf层级下。

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

return PixelPerfectTool(
detector=NativeGridDetector(),
reconstructor=NativeGridReconstructor(),
max_concurrency=max_concurrency,
)
18 changes: 18 additions & 0 deletions backend/packages/app/src/windup_app/server/pixel_perfect/model.py
Original file line number Diff line number Diff line change
@@ -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
116 changes: 116 additions & 0 deletions backend/packages/app/src/windup_app/server/pixel_perfect/native.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
"""PyO3 原生扩展与 Python 工具编排之间的适配层。"""

from collections.abc import Callable
from importlib import import_module
import math
from typing import Protocol

from windup_app.server.pixel_perfect.errors import (
PixelPerfectInputError,
PixelPerfectUnavailableError,
)
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, module_loader: NativeModuleLoader = _load_installed_module
) -> None:
self._module_loader = module_loader

def detect(self, source: bytes) -> GridDetection:
native = _load_native(self._module_loader)
try:
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",
"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, module_loader: NativeModuleLoader = _load_installed_module
) -> None:
self._module_loader = module_loader

def reconstruct(self, source: bytes, *, cols: int, rows: int, colors: int) -> bytes:
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
Loading
Loading