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
21 changes: 11 additions & 10 deletions backend/packages/app/src/windup_app/server/orchestrator/executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,24 +99,24 @@ def _close_failed(session: Session, task_id: int, error_message: str) -> None:


# ── 项目全局约束(Project 表)→ 统合喂给生成逻辑 ─────────────────────────
# character_perspective 游戏视角:1=横版(侧视) 2=俯视 3=2.5D → 生成朝向/视角
_PERSPECTIVE_FACING: dict[int, str] = {1: "side", 2: "front", 3: "front"}
_PERSPECTIVE_VIEW: dict[int, str] = {
# directional_movement 是唯一方向规格(#664):
# 1=单向 → 横版侧视 / 1 向; 2=四向 → 俯视 / 4 向; 3=八向 → 2.5D / 8 向
_MOVEMENT_FACING: dict[int, str] = {1: "side", 2: "front", 3: "front"}
_MOVEMENT_VIEW: dict[int, str] = {
1: "side view, horizontal side-scroller",
2: "top-down view",
3: "2.5D three-quarter view",
}
# directional_movement 移动方向:1=单向 2=四向 3=八向 → 需生成的方向数
_MOVEMENT_DIRECTIONS: dict[int, int] = {1: 1, 2: 4, 3: 8}


@dataclass
class ProjectConstraints:
"""从 Project 取的全局生成约束,统一约束角色图/动作生成。"""

facing: str = "side" # character_perspective → 朝向(须与母版一致 #35)
facing: str = "side" # directional_movement → 朝向(须与母版一致 #35)
view: str = "side view, horizontal side-scroller"
perspective: int = 1 # 1横版 2俯视 3 2.5D
perspective: int = 1 # 由朝向派生:1横版 2俯视 3 2.5D
directions: int = 1 # directional_movement → 方向数(1/4/8)
sprite_w: int = 256 # 输出/切帧尺寸(关键)
sprite_h: int = 256
Expand Down Expand Up @@ -149,11 +149,12 @@ def _load_constraints(session: Session, project_id: int | None) -> ProjectConstr
if p is None:
return ProjectConstraints()
art_style = ArtStyle.from_stored(p.game_style)
movement = p.directional_movement
return ProjectConstraints(
facing=_PERSPECTIVE_FACING.get(p.character_perspective, "side"),
view=_PERSPECTIVE_VIEW.get(p.character_perspective, _PERSPECTIVE_VIEW[1]),
perspective=p.character_perspective,
directions=_MOVEMENT_DIRECTIONS.get(p.directional_movement, 1),
facing=_MOVEMENT_FACING.get(movement, "side"),
view=_MOVEMENT_VIEW.get(movement, _MOVEMENT_VIEW[1]),
perspective=movement if movement in _MOVEMENT_FACING else 1,
directions=_MOVEMENT_DIRECTIONS.get(movement, 1),
sprite_w=p.sprite_width,
sprite_h=p.sprite_height,
style=ArtStyle.phrase_from_stored(p.game_style),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ class Project(Base):
user_id: Mapped[int] = mapped_column(BigInteger, nullable=False)
workflow_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
project_name: Mapped[str] = mapped_column(String(20), nullable=False)
character_perspective: Mapped[int] = mapped_column(SmallInteger, nullable=False)
# 朝向是唯一的项目方向规格(#664):1=单向(横版侧视) 2=四向(俯视) 3=八向(2.5D)
directional_movement: Mapped[int] = mapped_column(SmallInteger, nullable=False)
sprite_width: Mapped[int] = mapped_column(SmallInteger, nullable=False)
sprite_height: Mapped[int] = mapped_column(SmallInteger, nullable=False)
Expand Down
51 changes: 38 additions & 13 deletions backend/packages/app/src/windup_app/web/api/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,14 @@
from datetime import datetime

from fastapi import APIRouter, Depends, Query, Request
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from pydantic import (
BaseModel,
ConfigDict,
Field,
computed_field,
field_validator,
model_validator,
)
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session

Expand Down Expand Up @@ -39,7 +46,9 @@ def _legacy_style_or_none(value: object) -> ArtStyle | str | None:
try:
return ArtStyle(value.strip())
except ValueError:
return ArtStyle.PIXEL if ArtStyle.from_stored(value) is ArtStyle.PIXEL else value
return (
ArtStyle.PIXEL if ArtStyle.from_stored(value) is ArtStyle.PIXEL else value
)


def _stored_style(style: ArtStyle | str | None) -> str | None:
Expand All @@ -53,13 +62,16 @@ def _stored_style(style: ArtStyle | str | None) -> str | None:


class ProjectCreate(BaseModel):
"""创建项目请求。"""
"""创建项目请求。

``character_perspective`` 已退役(#664):旧客户端多传该字段会被忽略,
方向规格只认 ``directional_movement``。
"""

workflow_id: int | None = None
project_name: str | None = Field(default=None, min_length=1, max_length=20)
name_context: str | None = None
character_perspective: int = Field(ge=1, le=3)
directional_movement: int = Field(ge=1, le=3)
directional_movement: int = Field(ge=1, le=3, description="1=单向 2=四向 3=八向")
sprite_width: int = Field(ge=32, le=2048)
sprite_height: int = Field(ge=32, le=2048)
game_style: ArtStyle | str = ArtStyle.UNSPECIFIED
Expand All @@ -80,9 +92,7 @@ class ProjectPatch(BaseModel):
game_style: ArtStyle | str | None = None
auto_pixelate: bool | None = None

_accept_legacy = field_validator("game_style", mode="before")(
_legacy_style_or_none
)
_accept_legacy = field_validator("game_style", mode="before")(_legacy_style_or_none)

@model_validator(mode="after")
def _at_least_one(self) -> "ProjectPatch":
Expand All @@ -96,14 +106,17 @@ def _at_least_one(self) -> "ProjectPatch":


class ProjectOut(BaseModel):
"""项目响应。"""
"""项目响应。

``character_perspective`` 不再落库,由 ``directional_movement`` 派生,
让尚未对齐的前端读列表/详情时不至于映射失败。
"""

model_config = ConfigDict(from_attributes=True)

id: int
workflow_id: int | None
project_name: str
character_perspective: int
directional_movement: int
sprite_width: int
sprite_height: int
Expand All @@ -113,6 +126,12 @@ class ProjectOut(BaseModel):
create_at: datetime
update_at: datetime

@computed_field
@property
def character_perspective(self) -> int:
"""与朝向 1:1:单向→横版、四向→俯视、八向→2.5D。"""
return self.directional_movement

@field_validator("game_style", mode="before")
@classmethod
def _normalize_style(cls, value: object) -> ArtStyle | str:
Expand All @@ -128,7 +147,9 @@ def _normalize_style(cls, value: object) -> ArtStyle | str:
try:
return ArtStyle(text)
except ValueError:
return ArtStyle.PIXEL if ArtStyle.from_stored(text) is ArtStyle.PIXEL else text
return (
ArtStyle.PIXEL if ArtStyle.from_stored(text) is ArtStyle.PIXEL else text
)


class ProjectListOut(ProjectOut):
Expand All @@ -145,7 +166,9 @@ def create_project(
) -> Response[ProjectOut]:
user_id = request.state.current_user.id
automatic_name = not (body.project_name or "").strip()
base_name = resolve_project_name(body.project_name, body.name_context, service._namer)
base_name = resolve_project_name(
body.project_name, body.name_context, service._namer
)
fields = body.model_dump(exclude={"project_name", "name_context"})
fields["game_style"] = _stored_style(body.game_style)

Expand All @@ -166,7 +189,9 @@ def create_project(
project = service.create_project(
session, user_id=user_id, project_name=project_name, **fields
)
return Response.success(ProjectOut.model_validate(project), message="创建成功")
return Response.success(
ProjectOut.model_validate(project), message="创建成功"
)
except IntegrityError:
logger.warning(
"[WINDUP] 创建并发重名 | user_id=%s project_name=%s",
Expand Down
14 changes: 7 additions & 7 deletions backend/packages/common/src/windup_common/models/character.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,24 +112,24 @@ class Facing(str, Enum):
- ``FRONT``:身体正对观者(俯视与 2.5D 都归此)。

与 :class:`CharacterView` 的对应关系:SIDE→SIDE;TOP_DOWN / ISOMETRIC→FRONT。
两者不合并成一个枚举:view 是项目级美术视角(对应 ``Project.character_perspective``,
决定母版怎么画),facing 是提示词模板的二选一(只区分"看得到侧面"和"正对镜头")。
两者不合并成一个枚举:view 是项目级美术视角( ``Project.directional_movement``
派生,决定母版怎么画),facing 是提示词模板的二选一(只区分"看得到侧面"和"正对镜头")。
"""

SIDE = "side"
FRONT = "front"


class CharacterView(str, Enum):
"""角色美术视角 —— 与 ``Project.character_perspective``(1/2/3)一一对应。
"""角色美术视角 —— 与 ``Project.directional_movement``(1/2/3)一一对应。

映射固定为 1→side、2→top-down、3→isometric。字符串取值必须逐字一致,
免得调用方再造一套别名(如 topdown / top_down / top-down 三写)。
"""

SIDE = "side" # perspective=1 横版
TOP_DOWN = "top-down" # perspective=2 俯视
ISOMETRIC = "isometric" # perspective=3 2.5D
SIDE = "side" # directional_movement=1 单向/横版
TOP_DOWN = "top-down" # directional_movement=2 四向/俯视
ISOMETRIC = "isometric" # directional_movement=3 八向/2.5D


class Stylize(str, Enum):
Expand Down Expand Up @@ -216,7 +216,7 @@ class ActionSpec(BaseModel):
# 于是"我要 1 色"拿到 2 色且无任何提示 —— 正是本项目最忌讳的静默纠正。
pixel_h: int = Field(default=100, ge=1) # 像素化目标高(角色像素行数)
palette_size: int = Field(default=32, ge=2) # 色板色数(1 色的像素画不存在)
# 生成提示词的朝向,**必须与母版朝向一致**(对应 Project.perspective)。
# 生成提示词的朝向,**必须与母版朝向一致**( Project.directional_movement 派生)。
facing: Facing = Facing.SIDE
# 项目方向集合中的一个真实源方向。镜像方向不会进入 ActionSpec,因为它不应
# 调用模型;前端/编排层会为每个源方向创建独立 GenerationTask。
Expand Down
1 change: 0 additions & 1 deletion backend/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,6 @@ def insert_project(session, **overrides) -> Project:
fields = {
"user_id": 1,
"project_name": "测试项目",
"character_perspective": 1,
"directional_movement": 2,
"sprite_width": 64,
"sprite_height": 64,
Expand Down
2 changes: 0 additions & 2 deletions backend/tests/test_art_style.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
def _payload(**overrides):
base = {
"project_name": "画风",
"character_perspective": 1,
"directional_movement": 2,
"sprite_width": 64,
"sprite_height": 64,
Expand Down Expand Up @@ -159,7 +158,6 @@ def test_constraints_follow_the_project_style(db_session, stored, stylize, phras
project = Project(
user_id=1,
project_name=f"约束-{stored}",
character_perspective=1,
directional_movement=1,
sprite_width=64,
sprite_height=64,
Expand Down
1 change: 0 additions & 1 deletion backend/tests/test_character_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,6 @@ def _create_project(
"/projects",
json={
"project_name": name,
"character_perspective": 1,
"directional_movement": directional_movement,
"sprite_width": 64,
"sprite_height": 64,
Expand Down
4 changes: 2 additions & 2 deletions backend/tests/test_character_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,8 +70,8 @@ def test_action_spec_restricted_fields_reject_typos(field, bad, good, member):
assert getattr(ActionSpec(action=ActionType.WALK, **{field: good}), field) is member


def test_character_view_rejects_typos_and_matches_perspective_mapping():
"""view 固定映射 perspective:1 side / 2 top-down / 3 isometric。
def test_character_view_rejects_typos_and_matches_movement_mapping():
"""view 固定映射朝向规格:1 side / 2 top-down / 3 isometric。

字符串必须逐字一致,免得将来做 int↔str 映射时出现
topdown / top_down / top-down 三种写法。
Expand Down
1 change: 0 additions & 1 deletion backend/tests/test_full_direction_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,6 @@ def _create_eight_way_project(auth_client) -> dict:
"/projects",
json={
"project_name": "八向集成项目",
"character_perspective": 1,
"directional_movement": 3,
"sprite_width": 64,
"sprite_height": 64,
Expand Down
1 change: 0 additions & 1 deletion backend/tests/test_generation_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@ def _create_project(
"/projects",
json={
"project_name": name,
"character_perspective": 1,
"directional_movement": directional_movement,
"sprite_width": 64,
"sprite_height": 64,
Expand Down
30 changes: 23 additions & 7 deletions backend/tests/test_generation_orchestration.py
Original file line number Diff line number Diff line change
Expand Up @@ -413,12 +413,12 @@ def generate(self, card, action, master, progress, canvas=None):
)


def test_project_perspective_constrains_facing(session_factory):
# perspective=2 front(见 executor._PERSPECTIVE_TO_FACING)
def test_project_movement_constrains_facing(session_factory):
# directional_movement=2 四向 → 俯视 front(见 executor._MOVEMENT_FACING)
with session_factory() as s:
proj = Project(
user_id=1, project_name="p", character_perspective=2,
directional_movement=1, sprite_width=64, sprite_height=64,
user_id=1, project_name="p",
directional_movement=2, sprite_width=64, sprite_height=64,
)
s.add(proj)
s.commit()
Expand All @@ -441,7 +441,23 @@ def test_project_perspective_constrains_facing(session_factory):

executor.run_action_task(task_id, action_input, project_id) # 带项目约束

assert spy.seen_facing == "front", "项目 perspective 应约束生成朝向"
assert spy.seen_facing == "front", "项目朝向规格应约束生成朝向"


def test_unidirectional_project_keeps_side_facing(session_factory):
with session_factory() as s:
proj = Project(
user_id=1, project_name="side",
directional_movement=1, sprite_width=64, sprite_height=64,
)
s.add(proj)
s.commit()
from windup_app.server.orchestrator.executor import _load_constraints

cons = _load_constraints(s, proj.id)
assert cons.facing == "side"
assert cons.perspective == 1
assert cons.directions == 1


def test_custom_action_reuses_oneshot_route_and_preserves_prompt(session_factory):
Expand Down Expand Up @@ -577,7 +593,7 @@ def _run_with_project(session_factory, spy, sprite=(64, 64)):
"""建一个指定 sprite 尺寸的项目,跑一次动作任务,返回 (task_id, project_id)。"""
with session_factory() as s:
proj = Project(
user_id=1, project_name="p", character_perspective=1,
user_id=1, project_name="p",
directional_movement=1, sprite_width=sprite[0], sprite_height=sprite[1],
)
s.add(proj)
Expand Down Expand Up @@ -967,7 +983,7 @@ def _delivered_colors(game_style: str | None, session_factory, monkeypatch) -> i
)
with session_factory() as s:
project = Project(
user_id=1, project_name=f"画风-{game_style}", character_perspective=1,
user_id=1, project_name=f"画风-{game_style}",
directional_movement=1, sprite_width=64, sprite_height=64,
game_style=game_style,
)
Expand Down
4 changes: 0 additions & 4 deletions backend/tests/test_generation_quota.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,6 @@ def test_submit_image_generation_reserves_prepaid_credit(auth_client, db_session
"/projects",
json={
"project_name": "积分项目",
"character_perspective": 1,
"directional_movement": 2,
"sprite_width": 64,
"sprite_height": 64,
Expand Down Expand Up @@ -121,7 +120,6 @@ def test_submit_image_generation_reserves_per_requested_image(auth_client, db_se
"/projects",
json={
"project_name": "按张计费",
"character_perspective": 1,
"directional_movement": 2,
"sprite_width": 64,
"sprite_height": 64,
Expand Down Expand Up @@ -160,7 +158,6 @@ def test_submit_rejects_when_credit_is_insufficient(auth_client, db_session):
"/projects",
json={
"project_name": "没钱项目",
"character_perspective": 1,
"directional_movement": 2,
"sprite_width": 64,
"sprite_height": 64,
Expand Down Expand Up @@ -190,7 +187,6 @@ def test_submit_rejects_when_credit_covers_one_image_but_not_three(auth_client,
"/projects",
json={
"project_name": "一张的钱不够三张",
"character_perspective": 1,
"directional_movement": 2,
"sprite_width": 64,
"sprite_height": 64,
Expand Down
1 change: 0 additions & 1 deletion backend/tests/test_generation_stream_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,6 @@
def _create_project(client, name: str = "SSE 项目") -> dict:
return client.post("/projects", json={
"project_name": name,
"character_perspective": 1,
"directional_movement": 2,
"sprite_width": 64,
"sprite_height": 64,
Expand Down
1 change: 0 additions & 1 deletion backend/tests/test_media_thumbnail.py
Original file line number Diff line number Diff line change
Expand Up @@ -271,7 +271,6 @@ def test_delete_character_cleans_card_thumbnails(mock_media_service, monkeypatch
"/projects",
json={
"project_name": "缩略图清理",
"character_perspective": 1,
"directional_movement": 1,
"sprite_width": 64,
"sprite_height": 64,
Expand Down
Loading
Loading