From f72da5919b6aaebd8ec5f62293085e6c6cd23036 Mon Sep 17 00:00:00 2001 From: xyh202131 <246811510+xyh202131@users.noreply.github.com> Date: Wed, 19 Aug 2026 19:01:28 +0800 Subject: [PATCH 01/15] feat(generation): add directional asset backend contract --- .../src/windup_ai_engine/master_check.py | 89 +-- .../src/windup_ai_engine/prompt/_framing.py | 17 +- .../src/windup_ai_engine/strategy/concrete.py | 80 ++- .../src/windup_app/server/character/model.py | 137 ++++- .../server/orchestrator/executor.py | 153 +++-- .../windup_app/server/orchestrator/model.py | 24 +- .../windup_app/server/orchestrator/recover.py | 11 +- .../server/orchestrator/task_repo.py | 6 +- .../app/src/windup_app/web/api/character.py | 50 +- .../app/src/windup_app/web/api/generation.py | 49 +- .../common/src/windup_common/directions.py | 73 +++ .../src/windup_common/enums/character.py | 7 +- .../src/windup_common/models/__init__.py | 2 + .../src/windup_common/models/character.py | 50 +- .../providers/render3d/interfaces.py | 7 +- .../providers/render3d/sprite.py | 10 +- backend/tests/test_character_api.py | 523 ++++++++++++++++-- backend/tests/test_directional_generation.py | 49 ++ backend/tests/test_generation_api.py | 53 +- .../tests/test_render3d_route_and_assets.py | 38 +- ...-four-eight-direction-generation-design.md | 182 ++++++ openapi.json | 179 +++++- 22 files changed, 1562 insertions(+), 227 deletions(-) create mode 100644 backend/packages/common/src/windup_common/directions.py create mode 100644 backend/tests/test_directional_generation.py create mode 100644 docs/superpowers/specs/2026-08-19-four-eight-direction-generation-design.md diff --git a/backend/packages/ai_engine/src/windup_ai_engine/master_check.py b/backend/packages/ai_engine/src/windup_ai_engine/master_check.py index 7d6d8146..4b2ce6dd 100644 --- a/backend/packages/ai_engine/src/windup_ai_engine/master_check.py +++ b/backend/packages/ai_engine/src/windup_ai_engine/master_check.py @@ -19,7 +19,7 @@ 拒绝与警告的分界**由判据能不能证伪决定,不由后果严重程度决定**。④⑤ 指向的是混元图生 3D 的硬约束(四肢粘连 → 绑骨绑不出腿;画面里有武器配件 → 明确不允许),后果比 ③ 更贵, 但两条判据都会在合法母版上误报,所以只能警告。**上层拿它们做什么决定**:摆在母版确认闸 -上给人看,让人在付费建 3D 之前决定"就用这张 / 重新生成三张";任何一条都不阻断流程。 +上给人看,让人在付费建 3D 之前决定"就用这张 / 重新生成两张";任何一条都不阻断流程。 **本层不判什么、为什么 —— 别把下面这些当成已经守住了:** - **画面里有没有文字**(提示词含 "reference sheet" 时生图模型会自己糊上标注,烤进母版 @@ -46,6 +46,7 @@ 纯 PIL / numpy,零 API,不联网。 """ + from __future__ import annotations import io @@ -63,10 +64,20 @@ ) from windup_ai_engine.postprocess.pack import FILL_H, FILL_W -__all__ = ["LIMB_BANDS", "MIN_EXTRA_COMPONENT_RATIO", "MIN_LIMB_RUN_PX", - "MIN_SUBJECT_AREA_RATIO", "MIN_SUBJECT_SIDE", "REJECT_ASPECT", - "MasterFacts", "check_master", "component_sizes", "limb_segments", - "main_component", "reject_aspect_for"] +__all__ = [ + "LIMB_BANDS", + "MIN_EXTRA_COMPONENT_RATIO", + "MIN_LIMB_RUN_PX", + "MIN_SUBJECT_AREA_RATIO", + "MIN_SUBJECT_SIDE", + "REJECT_ASPECT", + "MasterFacts", + "check_master", + "component_sizes", + "limb_segments", + "main_component", + "reject_aspect_for", +] # 主体宽高比上限。**由交付画布的几何推出,不是拍的**:align_bottom_center 按高定标 # (cell*FILL_H);主体 w/h 超过 FILL_W/FILL_H(≈1.55)后宽度兜底接管,交付主体高度 @@ -97,6 +108,7 @@ def reject_aspect_for(canvas: tuple[int, int] | None) -> float: cw, ch = canvas return REJECT_ASPECT * (cw / ch) + # 主体包围盒的最短边下限。下游 align_bottom_center 会把包围盒裁出来、NEAREST 放大到 # cell*FILL_H≈159px;8px 放大 20 倍是色块不是角色。更要紧的是:这么小的一块,四角 # 中位色启发式**区分不了它是主体还是一粒压缩噪点/水印**,判"有主体"本身就不成立。 @@ -127,8 +139,11 @@ def reject_aspect_for(canvas: tuple[int, int] | None) -> float: def _runs(row: np.ndarray, min_px: int = 1) -> list[tuple[int, int]]: """一行里的连通段 ``[(x0, x1), ...]``(半开),短于 ``min_px`` 的丢掉。""" edges = np.flatnonzero(np.diff(np.concatenate(([0], row.astype(np.int8), [0])))) - return [(int(a), int(b)) for a, b in zip(edges[::2], edges[1::2], strict=True) - if b - a >= min_px] + return [ + (int(a), int(b)) + for a, b in zip(edges[::2], edges[1::2], strict=True) + if b - a >= min_px + ] def limb_segments(mask: np.ndarray, box: tuple[int, int, int, int]) -> tuple[int, ...]: @@ -136,7 +151,11 @@ def limb_segments(mask: np.ndarray, box: tuple[int, int, int, int]) -> tuple[int x0, y0, x1, y1 = box span = y1 - y0 - 1 return tuple( - len(_runs(mask[min(y1 - 1, y0 + int(round(frac * span))), x0:x1], MIN_LIMB_RUN_PX)) + len( + _runs( + mask[min(y1 - 1, y0 + int(round(frac * span))), x0:x1], MIN_LIMB_RUN_PX + ) + ) for frac in LIMB_BANDS ) @@ -166,7 +185,7 @@ def find(i: int) -> int: label = len(parent) parent.append(label) for pa, pb, plabel in previous: - if a <= pb and pa <= b: # 端点相碰即视为连通 = 八邻接 + if a <= pb and pa <= b: # 端点相碰即视为连通 = 八邻接 ra, rb = find(label), find(plabel) if ra != rb: parent[rb] = ra @@ -208,12 +227,12 @@ class MasterFacts: """预检**量到**的母版形态。返回它而不是只返 None:通过时这些数进进度文案, 出问题时(比如误拒)一眼看得出引擎当时把什么当成了主体。""" - size: tuple[int, int] # 母版画布 (w, h) - subject_box: tuple[int, int, int, int] # 主体包围盒 (x0, y0, x1, y1),半开 - subject_ratio: float # 主体 w/h - subject_area_ratio: float # 主体像素 / 画幅像素 - limb_segments: tuple[int, ...] = () # LIMB_BANDS 各处的横向连通段数 - components: tuple[int, ...] = () # 够大的连通块像素数,从大到小 + size: tuple[int, int] # 母版画布 (w, h) + subject_box: tuple[int, int, int, int] # 主体包围盒 (x0, y0, x1, y1),半开 + subject_ratio: float # 主体 w/h + subject_area_ratio: float # 主体像素 / 画幅像素 + limb_segments: tuple[int, ...] = () # LIMB_BANDS 各处的横向连通段数 + components: tuple[int, ...] = () # 够大的连通块像素数,从大到小 warnings: tuple[MasterWarning, ...] = field(default_factory=tuple) def note(self) -> str: @@ -221,9 +240,11 @@ def note(self) -> str: w, h = self.size x0, y0, x1, y1 = self.subject_box tail = f";{len(self.warnings)} 条警告" if self.warnings else "" - return (f"母版 {w}×{h},主体 {x1 - x0}×{y1 - y0}" - f"(w/h {self.subject_ratio:.2f},占幅 {self.subject_area_ratio:.1%})" - f"{tail}") + return ( + f"母版 {w}×{h},主体 {x1 - x0}×{y1 - y0}" + f"(w/h {self.subject_ratio:.2f},占幅 {self.subject_area_ratio:.1%})" + f"{tail}" + ) def _decode(master: bytes) -> Image.Image: @@ -250,22 +271,26 @@ def _warnings( """把量到的两组数翻成警告。**只在信号明确时出声** —— 详见各条的假阳性来源。""" out: list[MasterWarning] = [] if segments and max(segments) < 2: - out.append(MasterWarning( - MasterWarningCode.LIMBS_FUSED, - f"下半身 {list(LIMB_BANDS)} 四处横切都只有 {list(segments)} 段主体像素," - "两腿之间量不到空隙。混元靠这道空隙分左右腿,粘连时会绑出一条腿的骨架," - "而接口不会报错。**侧视角色两腿前后重叠时本条必然误报**," - "确认这张是侧视就忽略它。", - )) + out.append( + MasterWarning( + MasterWarningCode.LIMBS_FUSED, + f"下半身 {list(LIMB_BANDS)} 四处横切都只有 {list(segments)} 段主体像素," + "两腿之间量不到空隙。混元靠这道空隙分左右腿,粘连时会绑出一条腿的骨架," + "而接口不会报错。**侧视角色两腿前后重叠时本条必然误报**," + "确认这张是侧视就忽略它。", + ) + ) if len(blocks) > 1: extra = ", ".join(f"{n}px" for n in blocks[1:]) - out.append(MasterWarning( - MasterWarningCode.EXTRA_COMPONENT, - f"主体({blocks[0]}px)之外还有 {len(blocks) - 1} 块独立色块({extra})。" - "混元明写送检模型不得含人体以外的组件,画面里的武器/道具会被一起建进网格、" - "再被绑上权重乱甩。也可能是生图模型自己糊上的标注文字。" - "**与身体相连的手持物本条逮不到**,只能靠人看。", - )) + out.append( + MasterWarning( + MasterWarningCode.EXTRA_COMPONENT, + f"主体({blocks[0]}px)之外还有 {len(blocks) - 1} 块独立色块({extra})。" + "混元明写送检模型不得含人体以外的组件,画面里的武器/道具会被一起建进网格、" + "再被绑上权重乱甩。也可能是生图模型自己糊上的标注文字。" + "**与身体相连的手持物本条逮不到**,只能靠人看。", + ) + ) return tuple(out) diff --git a/backend/packages/ai_engine/src/windup_ai_engine/prompt/_framing.py b/backend/packages/ai_engine/src/windup_ai_engine/prompt/_framing.py index 02966988..e08c39de 100644 --- a/backend/packages/ai_engine/src/windup_ai_engine/prompt/_framing.py +++ b/backend/packages/ai_engine/src/windup_ai_engine/prompt/_framing.py @@ -5,9 +5,12 @@ 只写正向计数句 —— 该 i2v 接口没有 negative_prompt,否定句里的名词会被 latch 进画面 (实测"do not add dust"反而勾出更多灰尘),所以说"恰好一个",不说"不要第二个"。 """ + from __future__ import annotations -__all__ = ["SINGLE_SUBJECT_FRAMING", "with_framing"] +from windup_common.directions import ActionDirection, direction_prompt + +__all__ = ["SINGLE_SUBJECT_FRAMING", "with_framing", "with_direction_lock"] # 攻击的两处留白(母版姿态要求 + 母版补边)让画面空得足以容下第二个主体。 SINGLE_SUBJECT_FRAMING = ( @@ -19,3 +22,15 @@ def with_framing(body: str) -> str: """给一段动作正文接上构图约束。""" return f"{body} {SINGLE_SUBJECT_FRAMING}" + + +def with_direction_lock(body: str, direction: ActionDirection | None) -> str: + """把显式方向锁放在动作模板之后,覆盖模型可能推断出的朝向。 + + 底层引擎仍允许旧调用不声明 ``direction``;这时保留原来的 facing 模板, + 不能擅自把它当成 east。服务端真正提交的生成任务会显式传入方向。 + """ + + if direction is None: + return body + return f"{body} {direction_prompt(direction)}" diff --git a/backend/packages/ai_engine/src/windup_ai_engine/strategy/concrete.py b/backend/packages/ai_engine/src/windup_ai_engine/strategy/concrete.py index 419f7d49..e9069853 100644 --- a/backend/packages/ai_engine/src/windup_ai_engine/strategy/concrete.py +++ b/backend/packages/ai_engine/src/windup_ai_engine/strategy/concrete.py @@ -9,6 +9,7 @@ VideoFrameStrategy 实测通路:严格侧面母版 → kling i2v(v2-5-turbo) → 抽单循环 N 帧 → matte 抠图 → 像素化。返回对齐前的 RGBA PNG 帧(对齐 / 打包在 CharacterGenerator 最后一公里)。 """ + from __future__ import annotations from typing import TYPE_CHECKING @@ -24,6 +25,7 @@ GenRoute, Stylize, ) +from windup_ai_engine.prompt._framing import with_direction_lock from windup_framework.providers import ImageProvider, MatteProvider, VideoProvider from windup_ai_engine._imgio import from_png as _img @@ -79,14 +81,18 @@ def _build_prompt(self, action: ActionSpec, stance: CharacterStance) -> str: # 兜一个默认值 —— 缺省只由 build_attack_prompt 定义一次,写两处会各自漂移。 if action.action is ActionType.ATTACK: if action.archetype is None: - return build_attack_prompt(facing=action.facing) - return build_attack_prompt(facing=action.facing, archetype=action.archetype) + body = build_attack_prompt(facing=action.facing) + else: + body = build_attack_prompt( + facing=action.facing, archetype=action.archetype + ) + return with_direction_lock(body, action.direction) builders = { ActionType.JUMP: build_jump_prompt, ActionType.IDLE: build_idle_prompt, } build = builders.get(action.action, build_walk_prompt) - return build(facing=action.facing) + return with_direction_lock(build(facing=action.facing), action.direction) def _custom_prompt(self, action: ActionSpec, stance: CharacterStance) -> str: """用户那句话先过适配器,再按声明的循环性收尾。 @@ -107,9 +113,15 @@ def _custom_prompt(self, action: ActionSpec, stance: CharacterStance) -> str: raise except Exception: # 适配器坏掉只该丢掉那层改写,不该把整条生成打死:骨架本身不依赖它。 - return build_custom_prompt(clause, facing=action.facing, cyclic=cyclic) + return with_direction_lock( + build_custom_prompt(clause, facing=action.facing, cyclic=cyclic), + action.direction, + ) # 兜底那支走 build_custom_prompt,构图约束在它内部加;这支绕过了它,要自己加。 - return with_framing(f"{adapted.text} {CYCLIC_TAIL if cyclic else ONESHOT_TAIL}") + return with_direction_lock( + with_framing(f"{adapted.text} {CYCLIC_TAIL if cyclic else ONESHOT_TAIL}"), + action.direction, + ) def derive( self, @@ -139,11 +151,11 @@ def derive( ref_h = float(_ys.max() - _ys.min()) if len(_ys) else None if is_cyclic(action): progress.step("derive", 1, 3, f"步态周期取 {n} 帧(无缝 loop)+ 抠图") - picked = pick_cycle(dense, n) # 单周期闭环(#21) + picked = pick_cycle(dense, n) # 单周期闭环(#21) else: progress.step("derive", 1, 3, f"裁动作区间取 {n} 帧(不闭环)+ 抠图") kind = "airborne" if action.action is ActionType.JUMP else "swing" - picked = pick_oneshot(dense, n, kind=kind) # 一次性动作:裁起止 + picked = pick_oneshot(dense, n, kind=kind) # 一次性动作:裁起止 cut = [_img(self._matte.cutout(_png(im))) for im in picked] # 风格化按需(见 ActionSpec.stylize):none=保留 i2v 画风(插画/伪 3D 角色); @@ -155,18 +167,23 @@ def derive( target_h, palette = action.pixel_h, None try: - logical_h, pal = master_pixel_spec(_img(master)) # 用原始母版,不用补过边的 - if logical_h > 8: # 母版确为像素画 → 按它的规格走 + logical_h, pal = master_pixel_spec(_img(master)) # 用原始母版,不用补过边的 + if logical_h > 8: # 母版确为像素画 → 按它的规格走 target_h, palette = logical_h, pal - except Exception: # 母版非像素画/量不出 → 回退通用量化 + except Exception: # 母版非像素画/量不出 → 回退通用量化 pass progress.step( - "derive", 2, 3, + "derive", + 2, + 3, f"像素化(h={target_h}{'·锁母版色板' if palette is not None else '·通用量化'})", ) pix = pixelate_frames( - cut, target_h=target_h, palette_size=action.palette_size, - palette=palette, ref_height=ref_h, + cut, + target_h=target_h, + palette_size=action.palette_size, + palette=palette, + ref_height=ref_h, ) return [_png(p) for p in pix] @@ -208,8 +225,19 @@ def derive( # 几乎相同,靠轮廓分不出正反,而单元测试也逮不到 —— 朝向错了但帧数、时长、成色全部正常。 # 改这张表之前先渲一遍再量。 _FACING_TO_DIRECTION: dict[Facing, str] = { - Facing.SIDE: "e", # yaw=0°,角色朝画面右(与出帧台 faces="right" 同口径) - Facing.FRONT: "n", # yaw=90°,身体正对观者 + Facing.SIDE: "e", # yaw=0°,角色朝画面右(与出帧台 faces="right" 同口径) + Facing.FRONT: "n", # yaw=90°,身体正对观者 +} + +_ACTION_DIRECTION_TO_RENDERER: dict[str, str] = { + "east": "e", + "west": "w", + "north": "n", + "south": "s", + "north_east": "ne", + "north_west": "nw", + "south_east": "se", + "south_west": "sw", } @@ -255,16 +283,24 @@ def derive( "server 侧应在调用前确认该造型的 3D 资产可读。" ) - want = _FACING_TO_DIRECTION.get(action.facing, "e") + # 新请求按真实源方向取序列;旧的三渲二调用没有 direction 时,继续按 + # facing 选择序列,不能把缺省值误当成 east。 + if action.direction is None: + want = _FACING_TO_DIRECTION.get(action.facing, "e") + else: + want = _ACTION_DIRECTION_TO_RENDERER[action.direction.value] progress.step( - "derive", 0, 3, - f"渲 {self._directions} 朝向 × {action.n_frames} 帧" + "derive", + 0, + 3, + f"渲 {want} 朝向 × {action.n_frames} 帧" f"({self._size[0]}×{self._size[1]},材质 {self._material})", ) sheet: SpriteSheet = self._renderer.render( rigged_model, clip=action.action.value, directions=self._directions, + direction=want, frames=action.n_frames, size=self._size, material=self._material, @@ -291,12 +327,16 @@ def derive( # 不写成 warning 日志而是进度文案,因为这串字最终会经 server 到用户眼前, # 而"多朝向"正是这条路线的卖点 —— 用户该知道它已经算好了。 progress.step( - "derive", 1, 3, + "derive", + 1, + 3, f"已渲 {len(available)} 个朝向,本次出参只带 {chosen.direction};" f"其余 {','.join(extra)} 零成本可用但当前契约装不下(#122)", ) else: - progress.step("derive", 1, 3, f"朝向 {chosen.direction} 共 {len(frames)} 帧") + progress.step( + "derive", 1, 3, f"朝向 {chosen.direction} 共 {len(frames)} 帧" + ) # 3D 帧本来就是透明底,**不套抠图**:去白边那一步会把浅灰甲当漏白吃掉。 # 像素化仍按 ActionSpec 走。 diff --git a/backend/packages/app/src/windup_app/server/character/model.py b/backend/packages/app/src/windup_app/server/character/model.py index b330df55..c67802b3 100644 --- a/backend/packages/app/src/windup_app/server/character/model.py +++ b/backend/packages/app/src/windup_app/server/character/model.py @@ -10,6 +10,7 @@ windup_character └── character_data JSONB: 角色完整数据 + ├── templates[] list[CharacterTemplateSequence]: 各源方向母版与镜像关系 └── outfits[] list[CharacterOutfit]: 造型列表 ├── id str: 造型稳定 ID ├── name str: 造型名称 @@ -34,8 +35,9 @@ """ from datetime import datetime, timezone +from typing import Literal -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, model_validator from sqlalchemy import ( BigInteger, DateTime, @@ -127,6 +129,85 @@ class CharacterFrame(BaseModel): duration_ms: int | None = Field(default=None, gt=0, description="帧时长(毫秒)") +ActionDirection = Literal[ + "east", + "west", + "north", + "south", + "north_east", + "north_west", + "south_east", + "south_west", +] + +_MIRROR_SOURCES: dict[str, str] = { + "west": "east", + "north_west": "north_east", + "south_west": "south_east", +} + + +class CharacterActionSequence(BaseModel): + """一个动作的源方向帧或水平镜像关系。""" + + direction: ActionDirection + source_direction: ActionDirection | None = Field( + default=None, description="镜像方向引用的真实源方向" + ) + mirror_x: bool = Field(default=False, description="是否水平镜像源方向") + frame_count: int = Field(ge=0, description="该方向声明的帧数") + frames: list[CharacterFrame] = Field( + default_factory=list, description="该方向帧列表" + ) + + @model_validator(mode="after") + def validate_source_or_mirror(self) -> "CharacterActionSequence": + expected_source = _MIRROR_SOURCES.get(self.direction) + if expected_source is None: + if self.mirror_x or self.source_direction is not None: + raise ValueError("动作方向镜像关系无效") + if self.frame_count != len(self.frames) or self.frame_count == 0: + raise ValueError("源动作方向必须包含与 frame_count 一致的真实帧") + if sorted(frame.index for frame in self.frames) != list( + range(self.frame_count) + ): + raise ValueError("源动作方向帧序号必须从 0 连续递增") + return self + + if not self.mirror_x or self.source_direction != expected_source: + raise ValueError("动作方向镜像关系无效") + if self.frames: + raise ValueError("镜像动作方向不能保存独立帧") + return self + + +class CharacterTemplateSequence(BaseModel): + """角色母版的真实源方向或水平镜像关系。""" + + direction: ActionDirection + source_direction: ActionDirection | None = Field( + default=None, description="镜像方向引用的真实源方向" + ) + mirror_x: bool = Field(default=False, description="是否水平镜像源方向") + image_url: str | None = Field(default=None, description="真实源方向的母版 URL") + + @model_validator(mode="after") + def validate_source_or_mirror(self) -> "CharacterTemplateSequence": + expected_source = _MIRROR_SOURCES.get(self.direction) + if expected_source is None: + if self.mirror_x or self.source_direction is not None: + raise ValueError("角色母版方向镜像关系无效") + if not self.image_url or not self.image_url.strip(): + raise ValueError("真实源方向必须包含角色母版 URL") + return self + + if not self.mirror_x or self.source_direction != expected_source: + raise ValueError("角色母版方向镜像关系无效") + if self.image_url is not None: + raise ValueError("镜像角色母版不能保存独立图片") + return self + + class CharacterAction(BaseModel): """动作(从属于某个造型)。""" @@ -137,6 +218,29 @@ class CharacterAction(BaseModel): fps: float = Field(default=12, gt=0, description="播放帧率") frame_count: int = Field(ge=0, description="帧数") frames: list[CharacterFrame] = Field(default_factory=list, description="帧列表") + sequences: list[CharacterActionSequence] = Field( + default_factory=list, + description="可选多方向源序列与镜像关系;旧数据的 frames 视为 east", + ) + + @model_validator(mode="after") + def validate_direction_relations(self) -> "CharacterAction": + by_direction: dict[str, CharacterActionSequence] = {} + for sequence in self.sequences: + if sequence.direction in by_direction: + raise ValueError("同一动作不能包含重复方向") + by_direction[sequence.direction] = sequence + + for sequence in self.sequences: + source_direction = sequence.source_direction + if source_direction is None: + continue + source = by_direction.get(source_direction) + if source is None or source.source_direction is not None or source.mirror_x: + raise ValueError("镜像动作方向缺少真实源方向") + if sequence.frame_count != source.frame_count: + raise ValueError("镜像动作方向帧数必须与源方向一致") + return self class CharacterOutfit(BaseModel): @@ -157,11 +261,38 @@ class CharacterOutfit(BaseModel): model_3d_url: str | None = Field( default=None, description="该造型的绑骨 3D 模型 URL;None = 未建,三渲二不可用" ) - actions: list[CharacterAction] = Field(default_factory=list, description="该造型下的动作列表") + actions: list[CharacterAction] = Field( + default_factory=list, description="该造型下的动作列表" + ) class CharacterData(BaseModel): - """角色完整数据(造型→动作→帧)。""" + """角色完整数据(方向母版与造型→动作→帧)。""" version: int = Field(default=1, description="结构版本") + templates: list[CharacterTemplateSequence] = Field( + default_factory=list, description="角色各源方向母版与镜像关系" + ) outfits: list[CharacterOutfit] = Field(default_factory=list, description="造型列表") + + @model_validator(mode="after") + def validate_template_relations(self) -> "CharacterData": + by_direction: dict[str, CharacterTemplateSequence] = {} + for template in self.templates: + if template.direction in by_direction: + raise ValueError("角色母版不能包含重复方向") + by_direction[template.direction] = template + + for template in self.templates: + source_direction = template.source_direction + if source_direction is None: + continue + source = by_direction.get(source_direction) + if ( + source is None + or source.source_direction is not None + or source.mirror_x + or not source.image_url + ): + raise ValueError("镜像角色母版缺少真实源方向") + return self diff --git a/backend/packages/app/src/windup_app/server/orchestrator/executor.py b/backend/packages/app/src/windup_app/server/orchestrator/executor.py index 02b02f20..aae5a10d 100644 --- a/backend/packages/app/src/windup_app/server/orchestrator/executor.py +++ b/backend/packages/app/src/windup_app/server/orchestrator/executor.py @@ -23,6 +23,7 @@ from sqlalchemy.orm import Session from windup_ai_engine.ports import PromptRejected +from windup_common.directions import direction_prompt from windup_common.models import ActionSpec, ActionType as EngineActionType, CharacterCard from windup_framework.config.quality_gate import settings as gate_settings @@ -54,6 +55,7 @@ def _settle_credit(session: Session, task_id: int, *, success: bool) -> None: else: billing.release_for_task(session, user_id=task.user_id, task_id=task.id) + # ── 项目全局约束(Project 表)→ 统合喂给生成逻辑 ───────────────────────── # character_perspective 游戏视角:1=横版(侧视) 2=俯视 3=2.5D → 生成朝向/视角 _PERSPECTIVE_FACING: dict[int, str] = {1: "side", 2: "front", 3: "front"} @@ -70,15 +72,15 @@ def _settle_credit(session: Session, task_id: int, *, success: bool) -> None: class ProjectConstraints: """从 Project 取的全局生成约束,统一约束角色图/动作生成。""" - facing: str = "side" # character_perspective → 朝向(须与母版一致 #35) + facing: str = "side" # character_perspective → 朝向(须与母版一致 #35) view: str = "side view, horizontal side-scroller" - perspective: int = 1 # 1横版 2俯视 3 2.5D - directions: int = 1 # directional_movement → 方向数(1/4/8) - sprite_w: int = 256 # 输出/切帧尺寸(关键) + perspective: int = 1 # 1横版 2俯视 3 2.5D + directions: int = 1 # directional_movement → 方向数(1/4/8) + sprite_w: int = 256 # 输出/切帧尺寸(关键) sprite_h: int = 256 - style: str = "" # game_style 画风 - stylize: str = "none" # 由 style 推:像素游戏 → pixel - sprite_sample_url: str = "" # 项目风格参考图 URL + style: str = "" # game_style 画风 + stylize: str = "none" # 由 style 推:像素游戏 → pixel + sprite_sample_url: str = "" # 项目风格参考图 URL def _load_constraints(session: Session, project_id: int | None) -> ProjectConstraints: @@ -215,12 +217,19 @@ def __init__( upload: Callable[[bytes], str] | None = None, fetch_master: Callable[[CharacterActionInput], bytes] | None = None, fetch_model3d: Callable[[str], bytes] | None = None, - fetch_constraints: Callable[[Session, int | None], ProjectConstraints] | None = None, + fetch_constraints: Callable[[Session, int | None], ProjectConstraints] + | None = None, session_factory: Callable[[], Session] | None = None, ) -> None: - self._generator = generator # None → 懒加载真实装配 + self._generator = generator # None → 懒加载真实装配 # 按视频模型名分桶的 generator 缓存(模型是 provider 的构造参数,不能事后换) - self._by_model: dict[str | None, CharacterGeneratorPort] = {} + # 三渲二的渲染方向数属于项目约束;同一视频模型在 4 向和 8 向项目中 + # 不是同一个缓存实例,否则先请求的项目会把后续项目的方向数锁死。 + # directions=1 保留旧的 model/None 键,避免已有注入测试和调用方失效; + # 多方向项目用二元组分桶,防止同一模型的方向配置互相污染。 + self._by_model: dict[ + str | None | tuple[str | None, int], CharacterGeneratorPort + ] = {} # 抠图 / 图生图 provider 与视频模型无关,所有模型桶共用一份:每个抠图实例都会 # 各自惰性加载一份 ONNX 会话,按桶各建等于把同一个模型在进程里装多次。 self._matte: MatteProvider | None = None @@ -231,8 +240,8 @@ def __init__( # 本执行器是进程级单例,而每个请求起一个线程跑 run_action_task,上面几个缓存 # 都是跨线程共用的可变状态。缺锁时并发首请求会各装一套(见 _get_generator)。 self._assembly_lock = threading.Lock() - self._upload = upload # None → 真实对象存储上传 - self._fetch_master = fetch_master # None → 下载 reference_image_urls[0] + self._upload = upload # None → 真实对象存储上传 + self._fetch_master = fetch_master # None → 下载 reference_image_urls[0] self._fetch_model3d = fetch_model3d # None → 下载 input.model_3d_url self._fetch_constraints = fetch_constraints # None → 查 project 全局约束 self._session_factory = session_factory # None → SessionLocal @@ -281,7 +290,10 @@ def run_action_task( logger.exception("动作任务 %s 失败", task_id) session.rollback() task_repo.update_status( - session, task_id, TaskStatus.FAILED, error_message=str(exc), + session, + task_id, + TaskStatus.FAILED, + error_message=str(exc), ) _settle_credit(session, task_id, success=False) if own: @@ -292,12 +304,15 @@ def run_action_task( # -- 内部 -------------------------------------------------------------- - def _produce_action(self, input: CharacterActionInput, cons: ProjectConstraints) -> dict: + def _produce_action( + self, input: CharacterActionInput, cons: ProjectConstraints + ) -> dict: """母版 → ai_engine 按项目尺寸出帧 → 逐帧上传 → 组结果 dict。 项目约束落实:``facing`` 随视角、``stylize`` 随画风(像素游戏→像素化)、 - 输出帧尺寸随 ``sprite_w×sprite_h``。方向数(directions)MVP 先出主方向, - 四向/八向为扩展(需多次生成或镜像)。 + 输出帧尺寸随 ``sprite_w×sprite_h``。四向/八向项目由上层为每个真实源方向 + 创建独立任务;本任务只生成 ``input.direction``,左右镜像由资产层复用,避免 + 为镜像方向重复调用模型和扣费。 **尺寸是传给引擎的,不是拿到帧再缩的。** 这里曾对每帧再做一次 ``_fit_to(png, sprite_w, sprite_h)``:引擎恒出 256,项目要 512 就等于二次 @@ -307,9 +322,13 @@ def _produce_action(self, input: CharacterActionInput, cons: ProjectConstraints) 现在把 ``canvas`` 交给引擎,它一次就出到项目尺寸,那一步整个不存在了。 """ if cons.directions > 1: - logger.info("项目要求 %s 方向,MVP 先出主方向(多方向待扩展)", cons.directions) + logger.info( + "项目要求 %s 方向,本任务只负责真实源方向 %s;镜像方向由资产层复用", + cons.directions, + input.direction.value, + ) # 视频 i2v 没有独立的 style reference 字段,风格约束走提示词文字 - desc_parts = [input.custom_prompt or ""] + desc_parts = [input.custom_prompt or "", direction_prompt(input.direction)] if cons.style: desc_parts.append(f"Art style: {cons.style}") # 体型必须从请求一路传到这里 —— 只在 ai_engine 侧加门禁的话,生产链路恒走 @@ -333,6 +352,7 @@ def _produce_action(self, input: CharacterActionInput, cons: ProjectConstraints) action=engine_action, poses=[""] * input.num_frames, facing=cons.facing, + direction=input.direction, stylize=cons.stylize, **extra, ) @@ -355,18 +375,17 @@ def _produce_action(self, input: CharacterActionInput, cons: ProjectConstraints) rigged = (self._fetch_model3d or self._download_model3d)(model_url) logger.info( "[gen] 造型 %s 有 3D 资产(%d bytes),走三渲二", - input.outfit_id or "?", len(rigged), + input.outfit_id or "?", + len(rigged), ) generated = self._get_generator( - _resolve_video_model(input.video_model)).generate_rendered( - card, action, rigged, progress, canvas=canvas - ) + _resolve_video_model(input.video_model), cons.directions + ).generate_rendered(card, action, rigged, progress, canvas=canvas) else: master = (self._fetch_master or self._download_master)(input) generated = self._get_generator( - _resolve_video_model(input.video_model)).generate( - card, action, master, progress, canvas=canvas - ) + _resolve_video_model(input.video_model), cons.directions + ).generate(card, action, master, progress, canvas=canvas) upload = self._upload or self._upload_frame checked = [_require_size(png, cons.sprite_w, cons.sprite_h) for png in generated.frames] @@ -379,6 +398,7 @@ def _produce_action(self, input: CharacterActionInput, cons: ProjectConstraints) result = { "type": "character_action", "action_type": input.action_type.value, + "direction": input.direction.value, "frames": frames, "quality": dataclasses.asdict(generated.quality), "prompt_version": generated.prompt_version, @@ -406,7 +426,11 @@ def _get_judge(self) -> JudgePort | None: self._judge = SufyJudgeProvider() return self._judge - def _get_generator(self, video_model: str | None = None) -> CharacterGeneratorPort: + def _get_generator( + self, + video_model: str | None = None, + directions: int = 1, + ) -> CharacterGeneratorPort: """懒装配 CharacterGenerator,按模型名分桶。 视频 provider 的模型是构造参数,不分桶的话第一个请求指定的模型会被后续所有请求 @@ -416,17 +440,21 @@ def _get_generator(self, video_model: str | None = None) -> CharacterGeneratorPo return self._generator # 命中缓存的快路径不进锁,否则每个请求都要在这里排一次队。只有装配新桶才上锁, # 锁内重查一次:两个线程同时错过同一个桶时,后进来的那个要看见前一个的成果。 - cached = self._by_model.get(video_model) + cache_key: str | None | tuple[str | None, int] + cache_key = video_model if directions == 1 else (video_model, directions) + cached = self._by_model.get(cache_key) if cached is not None: return cached with self._assembly_lock: - cached = self._by_model.get(video_model) + cached = self._by_model.get(cache_key) if cached is None: - cached = self._assemble(video_model) - self._by_model[video_model] = cached + cached = self._assemble(video_model, directions) + self._by_model[cache_key] = cached return cached - def _assemble(self, video_model: str | None) -> CharacterGeneratorPort: + def _assemble( + self, video_model: str | None, directions: int + ) -> CharacterGeneratorPort: """装一个模型桶。**调用方须持有 ``self._assembly_lock``**(会写共用 provider)。""" from windup_ai_engine.impl import CharacterGenerator from windup_ai_engine.strategy.concrete import ( @@ -452,7 +480,7 @@ def _assemble(self, video_model: str | None) -> CharacterGeneratorPort: strategies = { GenRoute.VIDEO_I2V: VideoFrameStrategy(video, self._matte), GenRoute.PER_FRAME: PerFrameStrategy(self._image, self._matte), - GenRoute.RENDER_3D: self._build_render3d(), + GenRoute.RENDER_3D: self._build_render3d(directions), } missing = set(GenRoute) - set(strategies) if missing: @@ -463,7 +491,7 @@ def _assemble(self, video_model: str | None) -> CharacterGeneratorPort: return CharacterGenerator(strategies) @staticmethod - def _build_render3d(): + def _build_render3d(directions: int): """三渲二的**渲帧**那一段。纯本地(node + playwright + three.js),零 API 成本。 真被请求时才 import 出帧台那套依赖:它只有这条路线用得着,装配期就要齐会让本来 @@ -485,9 +513,14 @@ def __init__(self) -> None: def derive(self, card, action, source, progress): if self._inner is None: from windup_ai_engine.strategy.concrete import RenderFrameStrategy - from windup_framework.providers.render3d import LocalSpriteRenderProvider - - self._inner = RenderFrameStrategy(LocalSpriteRenderProvider()) + from windup_framework.providers.render3d import ( + LocalSpriteRenderProvider, + ) + + self._inner = RenderFrameStrategy( + LocalSpriteRenderProvider(), + directions=directions, + ) return self._inner.derive(card, action, source, progress) return _LazyRenderStrategy() @@ -535,9 +568,10 @@ class ImageTaskExecutor: def __init__( self, *, - image=None, # None → 懒加载 SufyImageProvider - upload: Callable[[bytes], str] | None = None, # None → 真实对象存储上传 - fetch_ref: Callable[[str], bytes] | None = None, # None → 下载 reference_image_url + image=None, # None → 懒加载 SufyImageProvider + upload: Callable[[bytes], str] | None = None, # None → 真实对象存储上传 + fetch_ref: Callable[[str], bytes] + | None = None, # None → 下载 reference_image_url session_factory: Callable[[], Session] | None = None, ) -> None: self._image = image @@ -559,19 +593,27 @@ def run_image_task( task_repo.update_status(session, task_id, TaskStatus.RUNNING) if own: session.commit() - cons = _load_constraints(session, project_id) # 角色图也受项目约束 + cons = _load_constraints(session, project_id) # 角色图也受项目约束 urls = self._produce_image(input, cons) - task_repo.update_result(session, task_id, _IMAGE_RESULT, { - "type": "character_image", - "image_urls": urls, - }) + task_repo.update_result( + session, + task_id, + _IMAGE_RESULT, + { + "type": "character_image", + "direction": input.direction.value, + "image_urls": urls, + }, + ) _settle_credit(session, task_id, success=True) if own: session.commit() except Exception as exc: # noqa: BLE001 —— 兜底 logger.exception("图片任务 %s 失败", task_id) session.rollback() - task_repo.update_status(session, task_id, TaskStatus.FAILED, error_message=str(exc)) + task_repo.update_status( + session, task_id, TaskStatus.FAILED, error_message=str(exc) + ) _settle_credit(session, task_id, success=False) if own: session.commit() @@ -579,7 +621,9 @@ def run_image_task( if own: session.close() - def _produce_image(self, input: CharacterImageInput, cons: ProjectConstraints) -> list[str]: + def _produce_image( + self, input: CharacterImageInput, cons: ProjectConstraints + ) -> list[str]: """根据项目约束决定生成模式,返回 URL 列表。 模式判断: @@ -606,8 +650,15 @@ def _produce_image(self, input: CharacterImageInput, cons: ProjectConstraints) - pass # 风格参考图下载失败不阻断 # 3. 构建提示词 - base = input.prompt or "Clean full-body character reference of the figure in the image." - parts = [base, f"{cons.view}, full body head to feet, centered."] + base = ( + input.prompt + or "Clean full-body character reference of the figure in the image." + ) + parts = [ + base, + f"{cons.view}, full body head to feet, centered.", + direction_prompt(input.direction), + ] if cons.style: parts.append(f"Art style: {cons.style}.") parts.append("Plain light-gray background, no shadow.") @@ -651,8 +702,10 @@ def _upload_image(self, png: bytes) -> str: from windup_app.server.media.service import service as media_service meta = MediaUploadInput( - filename="character.png", content_type="image/png", - size=len(png), category=MediaCategory.REFERENCE_IMAGE, + filename="character.png", + content_type="image/png", + size=len(png), + category=MediaCategory.REFERENCE_IMAGE, ) return media_service.upload(png, meta).url diff --git a/backend/packages/app/src/windup_app/server/orchestrator/model.py b/backend/packages/app/src/windup_app/server/orchestrator/model.py index d08af85e..0d7550e2 100644 --- a/backend/packages/app/src/windup_app/server/orchestrator/model.py +++ b/backend/packages/app/src/windup_app/server/orchestrator/model.py @@ -15,6 +15,7 @@ from windup_common.models import CharacterStance from windup_framework.db import Base +from windup_common.directions import ActionDirection # -- 枚举 ---------------------------------------------------------------- @@ -23,8 +24,8 @@ class GenerationType(StrEnum): """生成任务类型——每新增一种生成能力,在此加一个成员。""" - CHARACTER_IMAGE = "character_image" # 角色参考图 - CHARACTER_ACTION = "character_action" # 角色动作帧序列 + CHARACTER_IMAGE = "character_image" # 角色参考图 + CHARACTER_ACTION = "character_action" # 角色动作帧序列 class ActionType(StrEnum): @@ -58,7 +59,9 @@ class CharacterImageInput: negative_prompt: str = "" width: int = 1024 height: int = 1024 - num_images: int = 1 + # 角色母版和动作首帧统一返回两张候选;API 层也会把这个值限制为 2。 + num_images: int = 2 + direction: ActionDirection = ActionDirection.EAST @dataclass @@ -70,7 +73,7 @@ class CharacterActionInput: custom_prompt: str | None = None reference_video_url: str | None = None reference_image_urls: list[str] = field(default_factory=list) - num_frames: int = 16 + num_frames: int = 32 # ── action_type=custom 才用到的两个(#239)────────────────────────────── # 这个动作是否循环播放。``None`` 原样往下传,由编排层兜成一次性:本层替调用方填默认值 # 的话,"没给"和"明确给了 False"从这里起就再也分不开了。 @@ -92,6 +95,7 @@ class CharacterActionInput: # 角色体型。``None`` 原样往下传,由编排层兜成双足 —— 本层替调用方填默认值的话, # "没给"与"明确给了 biped"从这里起就分不开了。判据见 prompt.adapter 的体型门禁。 stance: CharacterStance | None = None + direction: ActionDirection = ActionDirection.EAST # -- 出参(按任务类型细化,前端可直接回填 character 模块)------------------ @@ -101,12 +105,13 @@ class CharacterActionInput: class CharacterImageOutput: """角色图片生成结果。 - 前端拿到 ``image_urls`` 后写入 ``Character.reference_image_url``。 - 单张也用列表: ``["url"]``。 + 前端拿到 ``image_urls`` 后把两张候选交给工作流节点选择;只有被确认的图片 + 才写入 ``Character.reference_image_url``。 """ type: str = "character_image" image_urls: list[str] = field(default_factory=list) + direction: ActionDirection = ActionDirection.EAST @dataclass @@ -143,6 +148,7 @@ class CharacterActionOutput: judge: dict | None = None quality: dict | None = None prompt_version: str | None = None + direction: ActionDirection = ActionDirection.EAST # -- 任务记录 ------------------------------------------------------------ @@ -188,11 +194,13 @@ class GenerationTaskRecord(Base): user_id: Mapped[int] = mapped_column(BigInteger, nullable=False) project_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True) task_type: Mapped[str] = mapped_column( - Text, nullable=False, + Text, + nullable=False, default=GenerationType.CHARACTER_IMAGE.value, ) status: Mapped[str] = mapped_column( - Text, nullable=False, + Text, + nullable=False, default=TaskStatus.PENDING.value, ) input_payload: Mapped[dict] = mapped_column( diff --git a/backend/packages/app/src/windup_app/server/orchestrator/recover.py b/backend/packages/app/src/windup_app/server/orchestrator/recover.py index d8e33f10..23a3b248 100644 --- a/backend/packages/app/src/windup_app/server/orchestrator/recover.py +++ b/backend/packages/app/src/windup_app/server/orchestrator/recover.py @@ -41,7 +41,8 @@ def recover_orphaned_generation_tasks( """扫描未结清冻结的开放任务并恢复。调用方负责 commit。""" stale_cutoff = datetime.now(timezone.utc) - timedelta(seconds=running_stale_seconds) for task in task_repo.list_by_status( - session, (TaskStatus.PENDING, TaskStatus.RUNNING), + session, + (TaskStatus.PENDING, TaskStatus.RUNNING), ): if task.id is None: continue @@ -65,7 +66,9 @@ def _fail_unrecoverable(session: Session, task: GenerationTask) -> None: """没有冻结可退,也不重跑;只保证它不再停在开放态。""" assert task.id is not None task_repo.update_status( - session, task.id, TaskStatus.FAILED, + session, + task.id, + TaskStatus.FAILED, error_message="任务已中断,请重新提交", ) logger.warning("无冻结的开放任务已置为失败 | task_id=%s %s", task.id, task.status) @@ -74,7 +77,9 @@ def _fail_unrecoverable(session: Session, task: GenerationTask) -> None: def _fail_interrupted(session: Session, task: GenerationTask) -> None: assert task.id is not None task_repo.update_status( - session, task.id, TaskStatus.FAILED, + session, + task.id, + TaskStatus.FAILED, error_message="进程中断,已解冻积分", ) billing.release_for_task(session, user_id=task.user_id, task_id=task.id) diff --git a/backend/packages/app/src/windup_app/server/orchestrator/task_repo.py b/backend/packages/app/src/windup_app/server/orchestrator/task_repo.py index 37a2e898..128945fd 100644 --- a/backend/packages/app/src/windup_app/server/orchestrator/task_repo.py +++ b/backend/packages/app/src/windup_app/server/orchestrator/task_repo.py @@ -25,6 +25,7 @@ GenerationType, TaskStatus, ) +from windup_common.directions import ActionDirection logger = logging.getLogger("windup.task_repo") @@ -214,7 +215,8 @@ def get_task_by_user( def list_by_status( - session: Session, statuses: tuple[TaskStatus, ...], + session: Session, + statuses: tuple[TaskStatus, ...], ) -> list[GenerationTask]: """按状态列出任务(启动对账用)。""" values = [status.value for status in statuses] @@ -255,6 +257,7 @@ def _deserialize_result( return CharacterImageOutput( type=raw.get("type", "character_image"), image_urls=raw.get("image_urls", []), + direction=ActionDirection(raw.get("direction", ActionDirection.EAST.value)), ) if result_type == "character_action": from windup_app.server.orchestrator.model import CharacterActionFrame @@ -274,5 +277,6 @@ def _deserialize_result( judge=raw.get("judge"), quality=raw.get("quality"), prompt_version=raw.get("prompt_version"), + direction=ActionDirection(raw.get("direction", ActionDirection.EAST.value)), ) return None diff --git a/backend/packages/app/src/windup_app/web/api/character.py b/backend/packages/app/src/windup_app/web/api/character.py index 42f2efd6..81367672 100644 --- a/backend/packages/app/src/windup_app/web/api/character.py +++ b/backend/packages/app/src/windup_app/web/api/character.py @@ -73,23 +73,33 @@ def _extract_object_keys(character: Character) -> list[str]: """ prefix = storage_settings.download_base + "/" keys: list[str] = [] + seen: set[str] = set() + + def add_url(url: str | None) -> None: + if not url or not url.startswith(prefix): + return + key = url[len(prefix) :] + if key not in seen: + seen.add(key) + keys.append(key) # 参考图 - url = character.reference_image_url - if url and url.startswith(prefix): - keys.append(url[len(prefix):]) + add_url(character.reference_image_url) # character_data 内的 URL data = character.character_data or {} + for template in data.get("templates", []): + add_url(template.get("image_url")) for outfit in data.get("outfits", []): - url = outfit.get("preview_url") - if url and url.startswith(prefix): - keys.append(url[len(prefix):]) + add_url(outfit.get("preview_url")) for action in outfit.get("actions", []): - for frame in action.get("frames", []): - url = frame.get("image_url") - if url and url.startswith(prefix): - keys.append(url[len(prefix):]) + frame_groups = [action.get("frames", [])] + frame_groups.extend( + sequence.get("frames", []) for sequence in action.get("sequences", []) + ) + for frames in frame_groups: + for frame in frames: + add_url(frame.get("image_url")) return keys @@ -98,7 +108,11 @@ def _extract_object_keys(character: Character) -> list[str]: def _get_project_or_raise( - session: Session, project_id: int, user_id: int, *, for_update: bool = False, + session: Session, + project_id: int, + user_id: int, + *, + for_update: bool = False, ) -> Project: """校验项目存在且属于当前用户,否则抛 BizException。 @@ -111,7 +125,9 @@ def _get_project_or_raise( def get_character_with_auth( - session: Session, character_id: int, user_id: int, + session: Session, + character_id: int, + user_id: int, ) -> Character: """获取角色并校验其所属项目属于当前用户。 @@ -170,13 +186,19 @@ def list_characters( request: Request = None, page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), - status: int | None = Query(None, ge=0, le=1, description="按发布状态过滤: 0=草稿, 1=已发布"), + status: int | None = Query( + None, ge=0, le=1, description="按发布状态过滤: 0=草稿, 1=已发布" + ), session: Session = Depends(get_session), ) -> ListResponse[CharacterOut]: user_id = request.state.current_user.id _get_project_or_raise(session, project_id, user_id) items, total = character_service.list_characters( - session, project_id=project_id, page=page, page_size=page_size, status=status, + session, + project_id=project_id, + page=page, + page_size=page_size, + status=status, ) return ListResponse.success( [CharacterOut.model_validate(c) for c in items], diff --git a/backend/packages/app/src/windup_app/web/api/generation.py b/backend/packages/app/src/windup_app/web/api/generation.py index 8727e3da..d7dc8f49 100644 --- a/backend/packages/app/src/windup_app/web/api/generation.py +++ b/backend/packages/app/src/windup_app/web/api/generation.py @@ -25,6 +25,7 @@ from sqlalchemy.orm import Session from windup_common.enums.biz_code import BizCode +from windup_common.directions import ActionDirection, is_source_direction from windup_common.exceptions import BizException from windup_common.models import CharacterStance from windup_common.result import Response @@ -129,7 +130,7 @@ def publish( try: here = asyncio.get_running_loop() except RuntimeError: - here = None # 从没有 loop 的生成工作线程调用 + here = None # 从没有 loop 的生成工作线程调用 for queue, loop in list(self._queues.get((project_id, task_id), [])): if loop is here: @@ -143,7 +144,9 @@ def publish( except RuntimeError: # loop 已关闭(客户端断连后请求 loop 结束)。丢弃即可 —— 没有订阅者在等 # 这条消息,而任务状态本身已落库,重连后靠 GET /tasks/{id} 取。 - logger.debug("SSE loop 已关闭,丢弃事件 task_id=%d event=%s", task_id, event) + logger.debug( + "SSE loop 已关闭,丢弃事件 task_id=%d event=%s", task_id, event + ) # 全局实例,挂到 app.state.event_bus @@ -171,7 +174,8 @@ class CharacterImageGenerateRequest(BaseModel): # (2026-08-10 机器审逮到)。宽高上界按当前 i2v 与像素化管线的实际处理范围取。 width: int = Field(default=1024, ge=64, le=2048) height: int = Field(default=1024, ge=64, le=2048) - num_images: int = Field(default=1, ge=1, le=4) + num_images: int = Field(default=2, ge=2, le=2) + direction: ActionDirection = ActionDirection.EAST class CharacterActionGenerateRequest(BaseModel): @@ -188,7 +192,7 @@ class CharacterActionGenerateRequest(BaseModel): reference_video_url: str | None = None reference_image_urls: list[str] = Field(default_factory=list) # 同上:帧数决定抽帧与逐帧抠图的工作量,上界 64 已远超引擎能出的有效周期长度。 - num_frames: int = Field(default=16, ge=1, le=64) + num_frames: int = Field(default=32, ge=1, le=64) # ── action_type=custom 才用到(#239)─────────────────────────────────── # 这个动作是否循环播放。不给则编排层兜成一次性,也不按描述文字猜 —— 两个方向的代价 # 不对称:一次性动作被当成循环会让末帧接回首帧抽搐、产物不可用,反之只是不无缝闭环、 @@ -205,6 +209,7 @@ class CharacterActionGenerateRequest(BaseModel): # 它们,模型会凭空接上一对人的上肢,而帧数/时长/成色全部正常、没有一道会红。 # 不给则按双足处理:这是绝大多数角色的实情,而误判成非双足会把合法描述拒掉。 stance: CharacterStance | None = None + direction: ActionDirection = ActionDirection.EAST @model_validator(mode="after") def require_custom_prompt(self): @@ -298,8 +303,10 @@ def _outfit_model_3d_url(character: Character, outfit_id: str | None) -> str | N except ValidationError: # 结构对不上就当没有资产:这一步只决定"走哪条路线",不该因为 character_data # 里某个无关字段脏了就让整个动作生成起不来。走 i2v 仍然出得了帧。 - logger.warning("character %s 的 character_data 解析失败,三渲二判据按无资产处理", - character.id) + logger.warning( + "character %s 的 character_data 解析失败,三渲二判据按无资产处理", + character.id, + ) return None outfit = next((o for o in data.outfits if o.id == outfit_id), None) if outfit is None: @@ -330,6 +337,16 @@ def _validate_project_size(project: Project, width: int, height: int) -> None: ) +def _validate_project_direction(project: Project, direction: ActionDirection) -> None: + """只允许该项目的真实源方向进入生成队列,镜像方向不重复生成。""" + + if not is_source_direction(project.directional_movement, direction): + raise BizException( + f"方向 {direction.value} 不是当前项目的真实源方向,镜像方向由资产层复用", + code=BizCode.BAD_REQUEST, + ) + + def _publish_generation_after_commit( session: Session, publisher: MqPublisher, @@ -363,6 +380,7 @@ def submit_image_generation( user_id = request.state.current_user.id project = _get_project_or_raise(session, body.project_id, user_id) _validate_project_size(project, body.width, body.height) + _validate_project_direction(project, body.direction) input_data = CharacterImageInput( reference_image_url=body.reference_image_url, prompt=body.prompt, @@ -370,9 +388,13 @@ def submit_image_generation( width=body.width, height=body.height, num_images=body.num_images, + direction=body.direction, ) task = generation_service.generate_character_image( - session, user_id=user_id, project_id=body.project_id, input=input_data, + session, + user_id=user_id, + project_id=body.project_id, + input=input_data, ) # 生成任务要在 commit 之后再入队:任务行未提交时工作线程用自己的 session 读不到它, # update 会静默跳过,表现为任务永远停在 PENDING。 @@ -393,7 +415,8 @@ def submit_action_generation( ) -> Response[GenerationTaskOut]: """提交角色动作生成任务:建 PENDING 记录立即返回,实际生成后台跑。""" user_id = request.state.current_user.id - _get_project_or_raise(session, body.project_id, user_id) + project = _get_project_or_raise(session, body.project_id, user_id) + _validate_project_direction(project, body.direction) character = _get_character_or_raise(session, body.character_id, body.project_id) model_3d_url = _outfit_model_3d_url(character, body.outfit_id) _require_master(model_3d_url, body.reference_image_urls) @@ -407,13 +430,17 @@ def submit_action_generation( reference_image_urls=body.reference_image_urls, num_frames=body.num_frames, outfit_id=body.outfit_id, + direction=body.direction, # 路线选择在这里定死并写进入参,而不是留给编排层现查:这样"这次走的哪条路线" # 在任务入参上就是可见的,排查时不用去猜当时 DB 是什么状态。 model_3d_url=model_3d_url, stance=body.stance, ) task = generation_service.generate_character_action( - session, user_id=user_id, project_id=body.project_id, input=input_data, + session, + user_id=user_id, + project_id=body.project_id, + input=input_data, ) _publish_generation_after_commit( session, @@ -481,7 +508,9 @@ async def stream_task( async def _event_generator(): try: if terminal_event is not None: - payload = json.dumps(task_repo.task_event_payload(task), ensure_ascii=False) + payload = json.dumps( + task_repo.task_event_payload(task), ensure_ascii=False + ) yield f"event: {terminal_event}\ndata: {payload}\n\n" return while True: diff --git a/backend/packages/common/src/windup_common/directions.py b/backend/packages/common/src/windup_common/directions.py new file mode 100644 index 00000000..c5bd6a4d --- /dev/null +++ b/backend/packages/common/src/windup_common/directions.py @@ -0,0 +1,73 @@ +"""角色动作的方向契约。 + +方向生成只为真实源方向创建任务;west、north_west、south_west 是由对应源方向 +水平镜像得到的逻辑方向,不应再次调用模型或扣费。 +""" + +from enum import StrEnum + + +class ActionDirection(StrEnum): + EAST = "east" + WEST = "west" + NORTH = "north" + SOUTH = "south" + NORTH_EAST = "north_east" + NORTH_WEST = "north_west" + SOUTH_EAST = "south_east" + SOUTH_WEST = "south_west" + + +_SOURCE_DIRECTIONS: dict[int, tuple[ActionDirection, ...]] = { + 1: (ActionDirection.EAST,), + 2: ( + ActionDirection.EAST, + ActionDirection.NORTH, + ActionDirection.SOUTH, + ), + 3: ( + ActionDirection.EAST, + ActionDirection.NORTH, + ActionDirection.SOUTH, + ActionDirection.NORTH_EAST, + ActionDirection.SOUTH_EAST, + ), +} + +MIRROR_SOURCE_BY_DIRECTION: dict[ActionDirection, ActionDirection] = { + ActionDirection.WEST: ActionDirection.EAST, + ActionDirection.NORTH_WEST: ActionDirection.NORTH_EAST, + ActionDirection.SOUTH_WEST: ActionDirection.SOUTH_EAST, +} + + +def source_directions_for_movement(movement: int) -> tuple[ActionDirection, ...]: + """返回项目需要真实生成的源方向;未知项目配置按单向兼容。""" + + return _SOURCE_DIRECTIONS.get(movement, _SOURCE_DIRECTIONS[1]) + + +def is_source_direction(movement: int, direction: ActionDirection) -> bool: + """判断请求方向是否属于该项目的真实生成集合。""" + + return direction in source_directions_for_movement(movement) + + +_DIRECTION_PROMPTS: dict[ActionDirection, str] = { + ActionDirection.EAST: "The character faces and moves to the right, in the east direction.", + ActionDirection.WEST: "The character faces and moves to the left, in the west direction.", + ActionDirection.NORTH: "The character faces away from the viewer, toward the north direction.", + ActionDirection.SOUTH: "The character faces toward the viewer, toward the south direction.", + ActionDirection.NORTH_EAST: "The character faces diagonally away from the viewer and to the right, toward north-east.", + ActionDirection.NORTH_WEST: "The character faces diagonally away from the viewer and to the left, toward north-west.", + ActionDirection.SOUTH_EAST: "The character faces diagonally toward the viewer and to the right, toward south-east.", + ActionDirection.SOUTH_WEST: "The character faces diagonally toward the viewer and to the left, toward south-west.", +} + + +def direction_prompt(direction: ActionDirection) -> str: + """返回给图片/视频模型的强方向锁,避免模型自行转身。""" + + return ( + f"{_DIRECTION_PROMPTS[direction]} Keep this direction unchanged; do not turn." + ) diff --git a/backend/packages/common/src/windup_common/enums/character.py b/backend/packages/common/src/windup_common/enums/character.py index d81a4484..8c6d6e24 100644 --- a/backend/packages/common/src/windup_common/enums/character.py +++ b/backend/packages/common/src/windup_common/enums/character.py @@ -17,12 +17,15 @@ class CharacterStatus(IntEnum): def from_character_data(cls, character_data: dict) -> "CharacterStatus": """根据 character_data 推断发布状态。 - 判定规则:至少存在一条包含真实帧(frame_count > 0 且 frames 非空)的动作 - 即为已发布;否则为草稿。 + 判定规则:动作顶层或任一方向序列包含真实帧即为已发布;否则为草稿。 """ for outfit in character_data.get("outfits", []): for action in outfit.get("actions", []): frames = action.get("frames", []) if frames and action.get("frame_count", 0) > 0: return cls.PUBLISHED + for sequence in action.get("sequences", []): + frames = sequence.get("frames", []) + if frames and sequence.get("frame_count", 0) > 0: + return cls.PUBLISHED return cls.DRAFT diff --git a/backend/packages/common/src/windup_common/models/__init__.py b/backend/packages/common/src/windup_common/models/__init__.py index 075caa37..3b9bfdb2 100644 --- a/backend/packages/common/src/windup_common/models/__init__.py +++ b/backend/packages/common/src/windup_common/models/__init__.py @@ -10,6 +10,7 @@ GenRoute, Stylize, ) +from windup_common.directions import ActionDirection from windup_common.models.quality import JudgeVerdict __all__ = [ @@ -23,5 +24,6 @@ "DEFAULT_N_FRAMES", "CharacterCard", "ActionSpec", + "ActionDirection", "JudgeVerdict", ] diff --git a/backend/packages/common/src/windup_common/models/character.py b/backend/packages/common/src/windup_common/models/character.py index 35541e8b..b00ab34c 100644 --- a/backend/packages/common/src/windup_common/models/character.py +++ b/backend/packages/common/src/windup_common/models/character.py @@ -11,6 +11,7 @@ 枚举把这类错误从"生成完靠肉眼发现"提前到"构造 ActionSpec 时 ValidationError", 成本从一次付费生成降到零。``loop`` / ``stylize`` / ``view`` 同理。 """ + from __future__ import annotations from enum import Enum @@ -18,6 +19,8 @@ from pydantic import BaseModel, ConfigDict, Field, model_validator +from windup_common.directions import ActionDirection + # 未知字段一律报错(pydantic 默认是 extra="ignore",静默丢弃)。理由与本文件用枚举取代裸 # str 完全同源:字段名也是靠字符串传递的约束。`ActionSpec(action=..., n_frame=16)`(少个 s) # 在 ignore 下不报错、不生效,调用方以为要了 16 帧、实际拿到默认 8 帧;`CharacterCard( @@ -48,7 +51,7 @@ class ActionType(str, Enum): IDLE = "idle" WALK = "walk" RUN = "run" - JUMP = "jump" # 一次性动作,且要按状态切段(见 postprocess.split_jump_phases) + JUMP = "jump" # 一次性动作,且要按状态切段(见 postprocess.split_jump_phases) ATTACK = "attack" # slash / thrust / dash 归此 HIT = "hit" # 用户自述动作。与上面几个的**结构性差异**:上面每个都自带一套写死的产线设定 @@ -65,10 +68,10 @@ class AttackArchetype(str, Enum): 手握一件有宽面的长条物,喂空手 / 法杖 / 四足角色时模型会凭空补出那件东西来调和矛盾。 """ - SWEEP = "sweep" # 长条持物:横挥 / 下劈 - THRUST = "thrust" # 短持物或空手:直出 / 戳刺 + SWEEP = "sweep" # 长条持物:横挥 / 下劈 + THRUST = "thrust" # 短持物或空手:直出 / 戳刺 PROJECT = "project" # 远程:身体前压、送到位、终态保持 - LUNGE = "lunge" # 非双足:整体前扑,头部 / 前肢领先 + LUNGE = "lunge" # 非双足:整体前扑,头部 / 前肢领先 class CharacterStance(str, Enum): @@ -93,8 +96,8 @@ class GenRoute(str, Enum): (见 #81 #122),随实现一起加成员 —— 枚举加成员是纯加法,不构成破坏性变更。 """ - VIDEO_I2V = "video_i2v" # 步态位移动作:图生视频(连贯交替腿) - PER_FRAME = "per_frame" # 离散姿势:逐帧图生图(单帧可编辑) + VIDEO_I2V = "video_i2v" # 步态位移动作:图生视频(连贯交替腿) + PER_FRAME = "per_frame" # 离散姿势:逐帧图生图(单帧可编辑) # 三渲二:母版 → 图生 3D → 自动绑骨 → 套预设动作 → 渲 2D 序列帧。与上面两条有个 # **结构性差异**:前两条由动作的物理性质唯一决定,这一条还取决于"该造型有没有 3D # 资产"。所以它**不进 ROUTE_MATRIX** —— 由 server 读 DB 后直接调 @@ -124,8 +127,8 @@ class CharacterView(str, Enum): 免得调用方再造一套别名(如 topdown / top_down / top-down 三写)。 """ - SIDE = "side" # perspective=1 横版 - TOP_DOWN = "top-down" # perspective=2 俯视 + SIDE = "side" # perspective=1 横版 + TOP_DOWN = "top-down" # perspective=2 俯视 ISOMETRIC = "isometric" # perspective=3 2.5D @@ -156,9 +159,9 @@ class CharacterCard(BaseModel): model_config = _STRICT name: str - desc: str # 身份描述(喂模型锁一致性) + desc: str # 身份描述(喂模型锁一致性) view: CharacterView = CharacterView.SIDE - master_ref: str = "" # 定妆母版的存储 ref(对象存储,非本地路径) + master_ref: str = "" # 定妆母版的存储 ref(对象存储,非本地路径) version: str = "v1" # 体型。默认值等于对每个没声明体型的角色做一次断言,所以取断言最少的那个:双足这一支 @@ -211,10 +214,14 @@ class ActionSpec(BaseModel): # pixel_h → postprocess.to_pixel_art 对 <1 直接 raise,契约没理由比实现更宽松; # palette_size → 同处 `quantize(colors=max(2, palette_size))` 会把 1 静默抬成 2, # 于是"我要 1 色"拿到 2 色且无任何提示 —— 正是本项目最忌讳的静默纠正。 - pixel_h: int = Field(default=100, ge=1) # 像素化目标高(角色像素行数) - palette_size: int = Field(default=32, ge=2) # 色板色数(1 色的像素画不存在) + pixel_h: int = Field(default=100, ge=1) # 像素化目标高(角色像素行数) + palette_size: int = Field(default=32, ge=2) # 色板色数(1 色的像素画不存在) # 生成提示词的朝向,**必须与母版朝向一致**(对应 Project.perspective)。 facing: Facing = Facing.SIDE + # 项目方向集合中的一个真实源方向。镜像方向不会进入 ActionSpec,因为它不应 + # 调用模型;前端/编排层会为每个源方向创建独立 GenerationTask。 + # None 只表示旧的底层调用没有声明方向;服务端编排请求会始终显式传入方向。 + direction: ActionDirection | None = None # ── 仅 action=CUSTOM 用的两个字段(#239)──────────────────────────────── # @@ -250,7 +257,9 @@ def _custom_needs_its_own_settings(self) -> ActionSpec: """ if self.action is ActionType.CUSTOM: if not (self.custom_action or "").strip(): - raise ValueError("action=custom 必须给 custom_action(动作描述),否则无从构建提示词") + raise ValueError( + "action=custom 必须给 custom_action(动作描述),否则无从构建提示词" + ) if self.cyclic is None: raise ValueError( "action=custom 必须显式给 cyclic(是否循环播放)。不猜 —— " @@ -258,13 +267,16 @@ def _custom_needs_its_own_settings(self) -> ActionSpec: ) else: if self.custom_action is not None: - raise ValueError(f"action={self.action.value} 不该带 custom_action;它的提示词由模板给") + raise ValueError( + f"action={self.action.value} 不该带 custom_action;它的提示词由模板给" + ) if self.cyclic is not None: raise ValueError( f"action={self.action.value} 不该带 cyclic;循环性由 CYCLIC_ACTIONS 写死," "传了不会生效" ) return self + # 这里**没有** ``route`` 字段:路线选择整个在 server —— 走不走三渲二取决于"这个造型 # 有没有 3D 资产",那份数据在 DB 里,server 读完直接调 ``generate_rendered``。 # 加一个零消费方的字段等于留一个"填了看起来会生效、实际没人读"的入参。 @@ -281,7 +293,7 @@ def _reconcile_n_frames_with_poses(cls, data: Any) -> Any: - 显式 ``None`` 一律等同"没传"(两条分支一致):调用方写 ``n_frames=form.get("n_frames")`` 时 None 表示"未指定",该走缺省,不该炸。 """ - if not isinstance(data, dict): # model_validate(实例) 等非 dict 入参原样放行 + if not isinstance(data, dict): # model_validate(实例) 等非 dict 入参原样放行 return data n = data.get("n_frames") poses = data.get("poses") @@ -289,7 +301,11 @@ def _reconcile_n_frames_with_poses(cls, data: Any) -> Any: # 有 poses 就回退到 len(poses),没有则删键让字段缺省值(DEFAULT_N_FRAMES)生效。 # 不能原样留 None:`n_frames: int` 会报 "Input should be a valid integer", # 于是"显式 None"在有/无 poses 两种情况下行为不一致(一个回退、一个报错)。 - return {**data, "n_frames": len(poses)} if poses else _without(data, "n_frames") + return ( + {**data, "n_frames": len(poses)} + if poses + else _without(data, "n_frames") + ) if not poses: return data # 先按 int 归一再比:pydantic 之后会把 JSON 里的 "2" 收成 2,而这里若直接 `n != len` @@ -298,7 +314,7 @@ def _reconcile_n_frames_with_poses(cls, data: Any) -> Any: try: n_int = int(n) except (TypeError, ValueError): - return data # 类型本就不对 → 交给字段校验报正经的 int 错 + return data # 类型本就不对 → 交给字段校验报正经的 int 错 if n_int != len(poses): raise ValueError( f"n_frames={n_int} 与 len(poses)={len(poses)} 不一致;" diff --git a/backend/packages/framework/src/windup_framework/providers/render3d/interfaces.py b/backend/packages/framework/src/windup_framework/providers/render3d/interfaces.py index 0fb50976..0d7382b7 100644 --- a/backend/packages/framework/src/windup_framework/providers/render3d/interfaces.py +++ b/backend/packages/framework/src/windup_framework/providers/render3d/interfaces.py @@ -284,8 +284,10 @@ class SpriteRenderProvider(Protocol): 只换相机方位角重渲一遍,各朝向天生一致(同一网格、同一骨骼、同一采样时刻)。 逐帧 / 视频路线做同样的事是 N 倍生成费用,且各朝向之间没有一致性保证。 - ``directions`` 只接受 4 或 8(8 向是 4 向的超集)。``material`` 必须是出帧台**真正认识** - 的取值,实现会校验 —— 详见 :mod:`.sprite` 里 ``MATERIALS`` 的注释。 + ``directions`` 只接受 4 或 8(8 向是 4 向的超集)。单方向任务通过 ``direction`` + 指定本次真正需要的相机方向,避免每个任务都重渲完整方向表;不传时保留完整表行为。 + ``material`` 必须是出帧台**真正认识**的取值,实现会校验 —— 详见 + :mod:`.sprite` 里 ``MATERIALS`` 的注释。 """ def render( @@ -294,6 +296,7 @@ def render( *, clip: str | None = None, directions: int = 4, + direction: str | None = None, frames: int = 12, size: tuple[int, int] = RENDER_SIZE, material: str = "cel", diff --git a/backend/packages/framework/src/windup_framework/providers/render3d/sprite.py b/backend/packages/framework/src/windup_framework/providers/render3d/sprite.py index b8fbd457..e6011f08 100644 --- a/backend/packages/framework/src/windup_framework/providers/render3d/sprite.py +++ b/backend/packages/framework/src/windup_framework/providers/render3d/sprite.py @@ -158,6 +158,7 @@ def render( *, clip: str | None = None, directions: int = 4, + direction: str | None = None, frames: int = 12, size: tuple[int, int] = RENDER_SIZE, material: str = "cel", @@ -176,7 +177,14 @@ def render( "找不到 three.js。装一份(npm i three)或用 WINDUP_THREE_DIR / " "LocalSpriteRenderProvider(three_dir=...) 指过去。") - table = DIRECTIONS_8 if directions == 8 else DIRECTIONS_4 + direction_table = DIRECTIONS_8 if directions == 8 else DIRECTIONS_4 + if direction is not None and direction not in direction_table: + raise ValueError(f"方向 {direction!r} 不属于当前 {directions} 向项目") + table = ( + {direction: direction_table[direction]} + if direction is not None + else direction_table + ) with tempfile.TemporaryDirectory(prefix="windup_bake_") as tmp: root = pathlib.Path(tmp) docroot = root / "www" diff --git a/backend/tests/test_character_api.py b/backend/tests/test_character_api.py index 2ff59762..89aea0ee 100644 --- a/backend/tests/test_character_api.py +++ b/backend/tests/test_character_api.py @@ -1,7 +1,10 @@ """角色 CRUD API 集成测试。""" +from types import SimpleNamespace + import pytest +from windup_app.web.api import character as character_api from windup_app.server.character.model import Character from windup_app.server.character.service import service as character_service from windup_common.enums.character import CharacterStatus @@ -24,13 +27,16 @@ def _inject_fake_character_namer(): def _create_project(auth_client, name: str = "默认项目") -> dict: """创建一个项目并返回响应 data。""" - return auth_client.post("/projects", json={ - "project_name": name, - "character_perspective": 1, - "directional_movement": 2, - "sprite_width": 64, - "sprite_height": 64, - }).json()["data"] + return auth_client.post( + "/projects", + json={ + "project_name": name, + "character_perspective": 1, + "directional_movement": 2, + "sprite_width": 64, + "sprite_height": 64, + }, + ).json()["data"] def _payload(project_id: int, **overrides): @@ -53,17 +59,26 @@ def _payload_with_frames(project_id: int, **overrides): "name": "有帧角色", "description": "包含真实帧", "character_data": { - "outfits": [{ - "id": "outfit-1", - "name": "默认造型", - "actions": [{ - "id": "action-1", - "type": "idle", - "name": "待机", - "frame_count": 1, - "frames": [{"index": 0, "image_url": "https://example.com/frame.png"}], - }], - }], + "outfits": [ + { + "id": "outfit-1", + "name": "默认造型", + "actions": [ + { + "id": "action-1", + "type": "idle", + "name": "待机", + "frame_count": 1, + "frames": [ + { + "index": 0, + "image_url": "https://example.com/frame.png", + } + ], + } + ], + } + ], }, } base.update(overrides) @@ -82,9 +97,204 @@ def test_character_model_defaults_to_draft(db_session): assert character.status == CharacterStatus.DRAFT +def test_extract_object_keys_includes_directional_action_frames(monkeypatch): + """删除角色时只清理真实方向帧,不为镜像方向重复清理。""" + monkeypatch.setattr( + character_api, + "storage_settings", + SimpleNamespace(download_base="https://assets.example.com"), + ) + character = Character( + project_id=1, + workflow_run_id=1, + reference_image_url="https://assets.example.com/characters/reference.png", + character_data={ + "templates": [ + { + "direction": "east", + "source_direction": None, + "mirror_x": False, + "image_url": "https://assets.example.com/characters/reference.png", + }, + { + "direction": "west", + "source_direction": "east", + "mirror_x": True, + "image_url": None, + }, + { + "direction": "north", + "source_direction": None, + "mirror_x": False, + "image_url": "https://assets.example.com/characters/north.png", + }, + ], + "outfits": [ + { + "preview_url": "https://assets.example.com/outfits/preview.png", + "actions": [ + { + "frames": [ + { + "image_url": "https://assets.example.com/actions/legacy.png", + } + ], + "sequences": [ + { + "direction": "north", + "source_direction": None, + "mirror_x": False, + "frames": [ + { + "image_url": "https://assets.example.com/actions/north.png", + } + ], + }, + { + "direction": "west", + "source_direction": "east", + "mirror_x": True, + "frames": [], + }, + ], + } + ], + } + ] + }, + ) + + assert character_api._extract_object_keys(character) == [ + "characters/reference.png", + "characters/north.png", + "outfits/preview.png", + "actions/legacy.png", + "actions/north.png", + ] + + # -- POST /characters -------------------------------------------------------- +def test_directional_character_templates_roundtrip(auth_client): + project = _create_project(auth_client) + templates = [ + { + "direction": "east", + "source_direction": None, + "mirror_x": False, + "image_url": "https://example.com/template-east.png", + }, + { + "direction": "west", + "source_direction": "east", + "mirror_x": True, + "image_url": None, + }, + { + "direction": "north", + "source_direction": None, + "mirror_x": False, + "image_url": "https://example.com/template-north.png", + }, + ] + payload = _payload( + project["id"], + reference_image_url="https://example.com/template-east.png", + character_data={"templates": templates, "outfits": []}, + ) + + created = auth_client.post("/characters", json=payload).json()["data"] + fetched = auth_client.get(f"/characters/{created['id']}").json()["data"] + + assert fetched["character_data"]["templates"] == templates + + +@pytest.mark.parametrize( + "templates", + [ + [ + { + "direction": "east", + "source_direction": None, + "mirror_x": True, + "image_url": "https://example.com/template-east.png", + } + ], + [ + { + "direction": "east", + "source_direction": None, + "mirror_x": False, + "image_url": " ", + } + ], + [ + { + "direction": "west", + "source_direction": "east", + "mirror_x": False, + "image_url": None, + } + ], + [ + { + "direction": "east", + "source_direction": None, + "mirror_x": False, + "image_url": "https://example.com/template-east.png", + }, + { + "direction": "west", + "source_direction": "east", + "mirror_x": True, + "image_url": "https://example.com/template-west.png", + }, + ], + [ + { + "direction": "east", + "source_direction": None, + "mirror_x": False, + "image_url": "https://example.com/template-east.png", + }, + { + "direction": "east", + "source_direction": None, + "mirror_x": False, + "image_url": "https://example.com/template-east-2.png", + }, + ], + [ + { + "direction": "west", + "source_direction": "east", + "mirror_x": True, + "image_url": None, + } + ], + ], + ids=[ + "source-mirror-flags", + "source-empty-image", + "mirror-flags", + "mirror-image", + "duplicate", + "missing-source", + ], +) +def test_rejects_invalid_directional_character_templates(auth_client, templates): + project = _create_project(auth_client) + payload = _payload( + project["id"], + character_data={"templates": templates, "outfits": []}, + ) + + response = auth_client.post("/characters", json=payload).json() + + assert response["code"] == 400 + + def test_create_with_name(auth_client): project = _create_project(auth_client) resp = auth_client.post("/characters", json=_payload(project["id"])) @@ -97,6 +307,206 @@ def test_create_with_name(auth_client): assert body["data"]["project_id"] == project["id"] +def test_directional_only_action_is_published_and_roundtrips(auth_client): + project = _create_project(auth_client) + payload = _payload( + project["id"], + character_data={ + "outfits": [ + { + "id": "outfit-1", + "name": "四向造型", + "actions": [ + { + "id": "walk-1", + "type": "walk", + "name": "四向行走", + "frame_count": 0, + "frames": [], + "sequences": [ + { + "direction": "east", + "source_direction": None, + "mirror_x": False, + "frame_count": 1, + "frames": [ + { + "index": 0, + "image_url": "https://example.com/walk-east.png", + } + ], + }, + { + "direction": "west", + "source_direction": "east", + "mirror_x": True, + "frame_count": 1, + "frames": [], + }, + ], + } + ], + } + ], + }, + ) + + created = auth_client.post("/characters", json=payload).json()["data"] + fetched = auth_client.get(f"/characters/{created['id']}").json()["data"] + + assert created["status"] == CharacterStatus.PUBLISHED + assert fetched["character_data"]["outfits"][0]["actions"][0]["sequences"] == [ + { + "direction": "east", + "source_direction": None, + "mirror_x": False, + "frame_count": 1, + "frames": [ + { + "index": 0, + "image_url": "https://example.com/walk-east.png", + "duration_ms": None, + } + ], + }, + { + "direction": "west", + "source_direction": "east", + "mirror_x": True, + "frame_count": 1, + "frames": [], + }, + ] + + +@pytest.mark.parametrize( + "sequences", + [ + [ + { + "direction": "east", + "source_direction": None, + "mirror_x": False, + "frame_count": 1, + "frames": [{"index": 0, "image_url": "https://example.com/east.png"}], + }, + { + "direction": "east", + "source_direction": None, + "mirror_x": False, + "frame_count": 1, + "frames": [{"index": 0, "image_url": "https://example.com/east-2.png"}], + }, + ], + [ + { + "direction": "south", + "source_direction": None, + "mirror_x": False, + "frame_count": 1, + "frames": [{"index": 0, "image_url": "https://example.com/south.png"}], + }, + { + "direction": "north", + "source_direction": "south", + "mirror_x": True, + "frame_count": 1, + "frames": [], + }, + ], + [ + { + "direction": "west", + "source_direction": "east", + "mirror_x": True, + "frame_count": 1, + "frames": [], + }, + ], + [ + { + "direction": "east", + "source_direction": None, + "mirror_x": False, + "frame_count": 1, + "frames": [{"index": 0, "image_url": "https://example.com/east.png"}], + }, + { + "direction": "west", + "source_direction": "east", + "mirror_x": True, + "frame_count": 1, + "frames": [{"index": 0, "image_url": "https://example.com/west.png"}], + }, + ], + [ + { + "direction": "east", + "source_direction": None, + "mirror_x": False, + "frame_count": 2, + "frames": [{"index": 0, "image_url": "https://example.com/east.png"}], + }, + ], + [ + { + "direction": "east", + "source_direction": None, + "mirror_x": False, + "frame_count": 1, + "frames": [{"index": 1, "image_url": "https://example.com/east.png"}], + }, + ], + [ + { + "direction": "west", + "source_direction": "east", + "mirror_x": False, + "frame_count": 1, + "frames": [], + }, + ], + [ + { + "direction": "east", + "source_direction": None, + "mirror_x": False, + "frame_count": 1, + "frames": [{"index": 0, "image_url": "https://example.com/east.png"}], + }, + { + "direction": "west", + "source_direction": "east", + "mirror_x": True, + "frame_count": 2, + "frames": [], + }, + ], + ], + ids=[ + "duplicate", + "illegal-pair", + "missing-source", + "derived-frames", + "source-frame-count", + "source-frame-index", + "mirror-flags", + "mirror-frame-count", + ], +) +def test_rejects_invalid_directional_sequence_relations(auth_client, sequences): + project = _create_project(auth_client) + payload = _payload_with_frames(project["id"]) + action = payload["character_data"]["outfits"][0]["actions"][0] + action["frames"] = [] + action["frame_count"] = 0 + action["sequences"] = sequences + + response = auth_client.post("/characters", json=payload).json() + + assert response["code"] == 400 + + def test_create_without_name(auth_client): project = _create_project(auth_client) resp = auth_client.post("/characters", json=_payload(project["id"], name=None)) @@ -109,7 +519,8 @@ def test_create_name_roundtrip(auth_client): """名称持久化后可通过 GET 读回。""" project = _create_project(auth_client) created = auth_client.post( - "/characters", json=_payload(project["id"], name="小精灵"), + "/characters", + json=_payload(project["id"], name="小精灵"), ).json()["data"] resp = auth_client.get(f"/characters/{created['id']}") @@ -144,16 +555,19 @@ def test_create_under_other_users_project_returns_404(auth_client, auth_client_b def test_create_same_workflow_run_under_another_project_returns_404( - auth_client, auth_client_b, + auth_client, + auth_client_b, ): project_a = _create_project(auth_client, "用户 A 项目") project_b = _create_project(auth_client_b, "用户 B 项目") created = auth_client.post( - "/characters", json=_payload(project_a["id"], workflow_run_id=42), + "/characters", + json=_payload(project_a["id"], workflow_run_id=42), ).json()["data"] resp = auth_client_b.post( - "/characters", json=_payload(project_b["id"], workflow_run_id=42), + "/characters", + json=_payload(project_b["id"], workflow_run_id=42), ) assert resp.json()["code"] == 404 @@ -176,7 +590,8 @@ def test_get_other_users_character_returns_404(auth_client, auth_client_b): """用户 B 不能查看用户 A 的角色。""" project = _create_project(auth_client) created = auth_client.post( - "/characters", json=_payload(project["id"]), + "/characters", + json=_payload(project["id"]), ).json()["data"] resp = auth_client_b.get(f"/characters/{created['id']}") @@ -189,11 +604,13 @@ def test_update_other_users_character_returns_404(auth_client, auth_client_b): """用户 B 不能修改用户 A 的角色。""" project = _create_project(auth_client) created = auth_client.post( - "/characters", json=_payload(project["id"]), + "/characters", + json=_payload(project["id"]), ).json()["data"] resp = auth_client_b.patch( - f"/characters/{created['id']}", json={"name": "黑化"}, + f"/characters/{created['id']}", + json={"name": "黑化"}, ) assert resp.json()["code"] == 404 @@ -204,7 +621,8 @@ def test_delete_other_users_character_returns_404(auth_client, auth_client_b): """用户 B 不能删除用户 A 的角色。""" project = _create_project(auth_client) created = auth_client.post( - "/characters", json=_payload(project["id"]), + "/characters", + json=_payload(project["id"]), ).json()["data"] resp = auth_client_b.delete(f"/characters/{created['id']}") @@ -240,10 +658,14 @@ def test_list_characters_filter_by_status(auth_client): # 创建草稿角色 auth_client.post("/characters", json=_payload(project["id"], workflow_run_id=1)) # 创建已发布角色 - auth_client.post("/characters", json=_payload_with_frames(project["id"], workflow_run_id=2)) + auth_client.post( + "/characters", json=_payload_with_frames(project["id"], workflow_run_id=2) + ) # 查询已发布角色 - resp = auth_client.get("/characters", params={"project_id": project["id"], "status": 1}) + resp = auth_client.get( + "/characters", params={"project_id": project["id"], "status": 1} + ) data = resp.json() assert data["code"] == 200 assert data["total"] == 1 @@ -251,7 +673,9 @@ def test_list_characters_filter_by_status(auth_client): assert data["data"][0]["status"] == 1 # 查询草稿角色 - resp = auth_client.get("/characters", params={"project_id": project["id"], "status": 0}) + resp = auth_client.get( + "/characters", params={"project_id": project["id"], "status": 0} + ) data = resp.json() assert data["code"] == 200 assert data["total"] == 1 @@ -263,7 +687,9 @@ def test_list_characters_without_status_returns_all(auth_client): """不传 status 参数时返回所有角色。""" project = _create_project(auth_client) auth_client.post("/characters", json=_payload(project["id"], workflow_run_id=1)) - auth_client.post("/characters", json=_payload_with_frames(project["id"], workflow_run_id=2)) + auth_client.post( + "/characters", json=_payload_with_frames(project["id"], workflow_run_id=2) + ) resp = auth_client.get("/characters", params={"project_id": project["id"]}) data = resp.json() @@ -276,7 +702,8 @@ def test_update_character_data_recalculates_status(auth_client): """更新 character_data 后应自动重新计算 status。""" project = _create_project(auth_client) created = auth_client.post( - "/characters", json=_payload(project["id"]), + "/characters", + json=_payload(project["id"]), ).json()["data"] # 初始为草稿 @@ -287,17 +714,26 @@ def test_update_character_data_recalculates_status(auth_client): f"/characters/{created['id']}", json={ "character_data": { - "outfits": [{ - "id": "outfit-1", - "name": "默认造型", - "actions": [{ - "id": "action-1", - "type": "idle", - "name": "待机", - "frame_count": 1, - "frames": [{"index": 0, "image_url": "https://example.com/frame.png"}], - }], - }], + "outfits": [ + { + "id": "outfit-1", + "name": "默认造型", + "actions": [ + { + "id": "action-1", + "type": "idle", + "name": "待机", + "frame_count": 1, + "frames": [ + { + "index": 0, + "image_url": "https://example.com/frame.png", + } + ], + } + ], + } + ], }, }, ) @@ -309,7 +745,8 @@ def test_update_character_with_null_character_data(auth_client): """更新 character_data 为 null 时应返回 400 错误。""" project = _create_project(auth_client) created = auth_client.post( - "/characters", json=_payload_with_frames(project["id"]), + "/characters", + json=_payload_with_frames(project["id"]), ).json()["data"] # 更新 character_data 为 null diff --git a/backend/tests/test_directional_generation.py b/backend/tests/test_directional_generation.py new file mode 100644 index 00000000..a4fa2fd6 --- /dev/null +++ b/backend/tests/test_directional_generation.py @@ -0,0 +1,49 @@ +"""多方向生成契约的最小测试。 + +这些测试先锁住跨层共同约定,再让 API、编排器和前端分别实现: +真实源方向各自生成,镜像方向只保存关系,不重复调用模型。 +""" + +from windup_ai_engine.strategy.concrete import VideoFrameStrategy +from windup_common.directions import ( + ActionDirection, + source_directions_for_movement, +) +from windup_common.models import ActionSpec, ActionType, CharacterStance, Facing + + +def test_source_direction_profile_has_one_three_or_five_real_tasks(): + assert source_directions_for_movement(1) == (ActionDirection.EAST,) + assert source_directions_for_movement(2) == ( + ActionDirection.EAST, + ActionDirection.NORTH, + ActionDirection.SOUTH, + ) + assert source_directions_for_movement(3) == ( + ActionDirection.EAST, + ActionDirection.NORTH, + ActionDirection.SOUTH, + ActionDirection.NORTH_EAST, + ActionDirection.SOUTH_EAST, + ) + + +def test_mirror_direction_is_never_a_generation_source(): + assert ActionDirection.WEST not in source_directions_for_movement(3) + assert ActionDirection.NORTH_WEST not in source_directions_for_movement(3) + assert ActionDirection.SOUTH_WEST not in source_directions_for_movement(3) + + +def test_action_prompt_contains_direction_lock(): + strategy = VideoFrameStrategy(video=None, matte=None) # type: ignore[arg-type] + prompt = strategy._build_prompt( + ActionSpec( + action=ActionType.WALK, + facing=Facing.SIDE, + direction=ActionDirection.NORTH_EAST, + ), + CharacterStance.BIPED, + ) + + assert "north-east" in prompt.lower() + assert "do not turn" in prompt.lower() diff --git a/backend/tests/test_generation_api.py b/backend/tests/test_generation_api.py index 02c14390..9428db8b 100644 --- a/backend/tests/test_generation_api.py +++ b/backend/tests/test_generation_api.py @@ -114,6 +114,33 @@ def test_action_generation_uses_token_user_without_body_user_id(auth_client): assert body["data"]["status"] == "pending" +def test_image_generation_rejects_mirrored_direction(auth_client): + project = _create_project(auth_client) + + response = auth_client.post( + "/generation/image", + json=_image_payload(project["id"], direction="west"), + ) + + body = response.json() + assert body["code"] == 400 + assert "镜像方向" in body["message"] + + +def test_action_generation_rejects_mirrored_direction(auth_client): + project = _create_project(auth_client) + character = _create_character(auth_client, project["id"]) + + response = auth_client.post( + "/generation/action", + json=_action_payload(project["id"], character["id"], direction="west"), + ) + + body = response.json() + assert body["code"] == 400 + assert "镜像方向" in body["message"] + + def test_action_character_must_belong_to_requested_project(auth_client): first_project = _create_project(auth_client, "项目一") second_project = _create_project(auth_client, "项目二") @@ -190,8 +217,9 @@ def test_image_endpoint_actually_creates_a_task_row(auth_client): assert body["data"] is not None, body task_id = body["data"]["id"] - got = auth_client.get(f"/generation/tasks/{task_id}", - params={"project_id": project["id"]}).json() + got = auth_client.get( + f"/generation/tasks/{task_id}", params={"project_id": project["id"]} + ).json() assert got["data"]["id"] == task_id assert got["data"]["status"] == "pending" @@ -217,8 +245,9 @@ def test_task_query_rejects_a_task_from_another_project(auth_client): other = _create_project(auth_client, "另一个项目") task_id = _submit_image(auth_client, other["id"])["data"]["id"] - got = auth_client.get(f"/generation/tasks/{task_id}", - params={"project_id": mine["id"]}).json() + got = auth_client.get( + f"/generation/tasks/{task_id}", params={"project_id": mine["id"]} + ).json() assert got["data"] is None, got @@ -242,11 +271,17 @@ def test_validation_error_message_tells_the_user_what_is_wrong(auth_client): 读不懂的"请求参数校验失败",而"custom 动作必须提供 custom_prompt"就在 data 里躺着。 """ project = _create_project(auth_client) - r = auth_client.post("/generation/action", json={ - "project_id": project["id"], "character_id": 1, - "action_type": "custom", "custom_prompt": "", - "num_frames": 32, "reference_image_urls": ["https://media.windup.xin/x.png"], - }) + r = auth_client.post( + "/generation/action", + json={ + "project_id": project["id"], + "character_id": 1, + "action_type": "custom", + "custom_prompt": "", + "num_frames": 32, + "reference_image_urls": ["https://media.windup.xin/x.png"], + }, + ) body = r.json() assert body["code"] == 400 assert body["message"] != "请求参数校验失败", "还是笼统文案,用户看不懂" diff --git a/backend/tests/test_render3d_route_and_assets.py b/backend/tests/test_render3d_route_and_assets.py index e382e0c2..7c146e4e 100644 --- a/backend/tests/test_render3d_route_and_assets.py +++ b/backend/tests/test_render3d_route_and_assets.py @@ -26,7 +26,7 @@ from windup_ai_engine.strategy.concrete import RenderFrameStrategy, VideoFrameStrategy from windup_app.server.orchestrator.executor import ActionTaskExecutor, ProjectConstraints from windup_app.server.orchestrator.model import ActionType as InputActionType -from windup_app.server.orchestrator.model import CharacterActionInput +from windup_app.server.orchestrator.model import ActionDirection, CharacterActionInput from windup_app.server.orchestrator.render3d_assets import ( LocalDirAssetStore, LocalDirModelReview, @@ -127,18 +127,28 @@ class _FakeRenderer: """出帧台替身。**只有它是假的** —— 真出帧台要 node + playwright + three.js, CI 里跑不了;而路线选择、策略装配、编排接线全部走真代码。""" - def __init__(self, directions=("e", "n", "w", "s")) -> None: + def __init__( + self, directions=("e", "n", "w", "s"), *, honor_requested=True + ) -> None: self.calls = 0 self.last_model: bytes | None = None self._directions = directions self.last_size: tuple[int, int] | None = None + self.last_direction: str | None = None + self._honor_requested = honor_requested def render(self, rigged_model, *, clip=None, directions=4, frames=12, - size=(1536, 2560), material="cel") -> SpriteSheet: + size=(1536, 2560), material="cel", direction=None) -> SpriteSheet: self.calls += 1 self.last_model = rigged_model self.last_size = size - return _sheet(self._directions, frames) + self.last_direction = direction + rendered = ( + (direction,) + if direction is not None and self._honor_requested + else self._directions + ) + return _sheet(rendered, frames) class _AutoApproveReview: @@ -390,9 +400,23 @@ def test_requested_facing_picks_the_matching_direction(): assert any("朝向 n" in n or "只带 n" in n for n in spy.notes), spy.notes +def test_direction_task_renders_only_its_requested_3d_direction(): + renderer = _FakeRenderer() + + RenderFrameStrategy(renderer, directions=8).derive( + _card(), + _spec(direction=ActionDirection.NORTH_EAST), + b"RIGGED", + _NullProgress(), + ) + + assert renderer.calls == 1 + assert renderer.last_direction == "ne" + + def test_missing_direction_raises_instead_of_handing_back_another(): """出帧台没出请求的朝向就报错。换一个交出去 = 角色朝反方向走,而没有任何一道会红。""" - renderer = _FakeRenderer(directions=("w", "s")) + renderer = _FakeRenderer(directions=("w", "s"), honor_requested=False) with pytest.raises(ValueError, match="没有产出朝向"): RenderFrameStrategy(renderer).derive(_card(), _spec(), b"RIGGED", _NullProgress()) @@ -400,7 +424,9 @@ def test_missing_direction_raises_instead_of_handing_back_another(): def test_extra_directions_are_reported_not_silently_dropped(): """多渲出来的朝向零成本、但出参装不下 —— 这笔浪费要**可见**。""" spy = _SpyProgress() - RenderFrameStrategy(_FakeRenderer(directions=("e", "n", "w", "s"))).derive( + RenderFrameStrategy( + _FakeRenderer(directions=("e", "n", "w", "s"), honor_requested=False) + ).derive( _card(), _spec(), b"RIGGED", spy, ) assert any("零成本可用但当前契约装不下" in n for n in spy.notes), spy.notes diff --git a/docs/superpowers/specs/2026-08-19-four-eight-direction-generation-design.md b/docs/superpowers/specs/2026-08-19-four-eight-direction-generation-design.md new file mode 100644 index 00000000..ed78b9b4 --- /dev/null +++ b/docs/superpowers/specs/2026-08-19-four-eight-direction-generation-design.md @@ -0,0 +1,182 @@ +# 四向与八向完整生产链路设计 + +## 目标 + +让 Project 的 `single`、`four-way`、`eight-way` 方向规格真正贯穿角色母版、动作首帧、完整动画、审核、Character 资产、预览台和导出。一个逻辑动作仍只有一条 WorkflowRun 分支,方向是动作内部的子状态,不为每个方向复制工作流节点。 + +本设计以用户确认的镜像规则替换 Issue #222 中“每个方向必须独立生成”的旧约束:左右方向允许水平镜像,上下方向必须独立生成;八向中的左上、左下分别由右上、右下水平镜像。 + +## 方向规则 + +统一使用屏幕坐标方向标识: + +- `east`:右 +- `west`:左 +- `north`:上 +- `south`:下 +- `north_east`:右上 +- `north_west`:左上 +- `south_east`:右下 +- `south_west`:左下 + +各项目规格的真实生成方向与镜像方向如下: + +| 项目规格 | 真实生成方向 | 镜像方向 | +| --- | --- | --- | +| `single` | `east` | `west <- east` | +| `four-way` | `east`, `north`, `south` | `west <- east` | +| `eight-way` | `east`, `north`, `south`, `north_east`, `south_east` | `west <- east`, `north_west <- north_east`, `south_west <- south_east` | + +所有镜像均为水平镜像。`north` 与 `south` 没有镜像关系,也不能互相替代。 + +## 资产契约 + +### 源序列与派生序列 + +Character 中的方向序列需要同时表达真实帧和镜像关系,但不重复保存图片: + +```ts +type ActionDirection = + | 'east' + | 'west' + | 'north' + | 'south' + | 'north_east' + | 'north_west' + | 'south_east' + | 'south_west' + +interface SourceActionSequence { + direction: ActionDirection + sourceDirection: null + mirrorX: false + frameCount: number + frames: Frame[] +} + +interface MirroredActionSequence { + direction: ActionDirection + sourceDirection: ActionDirection + mirrorX: true + frameCount: number + frames: [] +} +``` + +后端 Pydantic 模型使用同一语义的蛇形字段。校验规则为: + +- 同一动作不能出现重复方向或未知方向。 +- 源序列必须包含连续帧,派生序列不能保存帧。 +- 派生关系不能形成链或环,且只能使用上表允许的镜像对。 +- Project 要求的每个逻辑方向都必须能解析到一个真实源序列。 +- `frame_count`、尺寸、时长和审核状态按镜像对保持一致。 + +现有顶层 `frames` 继续作为旧资产兼容入口。在 `single` 项目中将其解释为 `east`,并派生 `west`;在四向或八向项目中不据此猜测缺失方向。 + +### 多方向角色母版 + +`character_data` 增加方向母版集合,使用与动作序列相同的源/派生关系。现有 `reference_image_url` 保留,作为旧资产和 `east` 主母版的兼容字段。动作某个源方向只能引用相同方向的已确认角色母版。 + +## 生成接口 + +不新增 HTTP 路由,继续复用: + +- `POST /generation/image` +- `POST /generation/action` +- `GET /generation/tasks/{task_id}` +- `GET /generation/tasks/{task_id}/stream` + +现有请求和结果增加可选 `direction` 字段。旧调用方不传时按 `east` 兼容;多方向调用必须显式传源方向,后端结果原样返回并校验一致。 + +每个真实源方向使用一条独立 GenerationTask: + +- 图片任务每个方向生成 2 张候选。 +- 四向角色母版或动作首帧共生成 6 张候选。 +- 八向角色母版或动作首帧共生成 10 张候选。 +- 完整动画只为 3 或 5 个真实源方向创建任务。 +- 镜像方向不创建任务、不上传重复图片、不单独扣费。 + +用户点击“生成全部方向”时,前端 Controller 按缺失源方向调用现有接口。任务由现有后端 Dispatcher 排队执行,不在前端并发压满 Provider。每个任务独立保存和恢复,因此失败方向可以单独重试,成功方向不会重复生成。 + +## WorkflowRun + +节点拓扑保持不变:角色母版节点和每个动作仍使用现有的一条节点链。方向状态保存在节点内部: + +- 角色母版节点:各源方向的任务引用、两张候选和已选图片。 +- 动作首帧节点:各源方向的任务引用、两张候选和已选首帧。 +- 完整动画节点:各源方向的任务引用和完成状态。 +- 审核节点:按源方向分组审核,并同时展示其镜像结果。 + +`WorkflowGenerationRef` 增加可选 `direction`。旧引用没有方向时按 `east` 解释。节点只有在项目要求的全部源方向完成后才进入下一阶段;完整动画只有在所有镜像组审核通过后才允许发布。 + +页面刷新后,Controller 根据节点中的任务引用恢复每个方向。某方向失败时保留其他方向的候选、选择和结果;重新生成只清理该源方向及其镜像审核状态。 + +## Quick Start 与 Workflow Editor + +两套界面继续共用同一 Run Session 和 Controller: + +- Quick Start 自动按项目规格处理 1、3 或 5 个源方向,显示总体进度和当前方向。 +- Workflow Editor 在现有节点内部增加方向标签,不复制节点。 +- 每个源方向展示 2 张候选,镜像方向展示实时镜像预览并标明来源。 +- 用户可以单独重做失败或不满意的源方向。 +- 镜像组审核同时展示源方向与派生方向;拒绝任一侧都会退回对应源方向。 + +## 预览台 + +预览台从输入向量解析逻辑方向: + +- 四向项目不进入斜向;同时按两个轴时按四向项目既定规则选择方向。 +- 八向项目允许组合键产生四个斜向。 +- 对角移动乘以 `sqrt(1/2)`,保证速度不快于单轴移动。 +- 播放器先解析逻辑方向对应的源序列,再根据 `mirrorX` 决定是否水平翻转。 +- 非移动动作保持位置不变,但仍按最后朝向播放对应方向。 +- 四向和八向缺少必要源方向时明确报错,不借用邻近方向。 + +## 角色详情与发布 + +角色详情按逻辑方向展示源序列和镜像预览,明确标识缺失、生成中、待审核和已通过。发布校验读取 Character 所属 Project 的方向规格;缺少任一源方向、镜像关系非法或审核未通过时,动作不能标记为完整。 + +## 导出 + +导出模型和 `meta.json` 必须包含明确的 `direction`,不能再只通过文件名暗示方向。 + +源方向直接使用原始 PNG。镜像方向在导出阶段通过 Canvas 水平翻转,生成独立的逐帧 PNG 和 Sprite Sheet;最终导出包包含完整 2、4 或 8 个方向,但 Character 与对象存储仍只保存真实源帧。导出前校验: + +- 项目要求的方向全部可解析。 +- 所有源方向均已审核通过。 +- 镜像关系合法且源方向存在。 +- 各方向帧数、尺寸、索引和播放节奏一致。 +- 元数据、逐帧目录和图集使用同一方向标识。 + +## 失败与恢复 + +- 单个方向任务失败不会把其他方向改成失败或删除其结果。 +- 提交任务成功但 WorkflowRun 附加失败时,沿用现有未附加任务恢复机制。 +- SSE 不可用时继续使用现有任务轮询兜底。 +- 页面关闭或进程重启后,任务恢复仍由现有任务记录和 Dispatcher 恢复机制负责。 +- 镜像方向没有独立任务;它的状态始终由源方向和镜像规则派生。 + +## 实施拆分 + +为控制评审规模,按以下顺序提交: + +1. 方向契约与 PR368:将 `side/front/back` 收敛为完整八向标识,补源/派生序列、Character 往返和预览台八向解析。 +2. 方向生成:复用现有生成接口,增加 `direction` 字段、每方向两张候选和独立任务结果。 +3. 工作流:为现有节点增加方向子状态,接入 Quick Start、Workflow Editor、局部重做和恢复。 +4. 发布与消费:补齐逐方向审核、角色详情、发布门禁和完整方向导出。 +5. 真实验收:分别生成四向和八向角色的 idle、walk,完成审核、Playtest 和导出。 + +后续 PR 可以依赖前序 PR,但每个 PR 必须保持可测试、可回滚,不把全部改动压成一个数千行提交。 + +## 验收标准 + +- 四向真实生成 `east/north/south`,`west` 正确镜像。 +- 八向真实生成五个源方向,三个左侧方向正确镜像。 +- 每个源方向只提供两张候选,并能单独选择和重做。 +- 上下方向永远使用独立任务与独立资产。 +- 刷新和失败重试不会重复生成已完成方向。 +- Quick Start 与 Workflow Editor 对同一 WorkflowRun 显示一致状态。 +- Character 可以无损保存和读取源序列及镜像关系。 +- Playtest 的四向、八向移动、朝向和斜向速度正确。 +- 导出包包含完整方向的 PNG、图集和方向元数据。 +- 旧单序列角色仍可查看、试玩和导出。 diff --git a/openapi.json b/openapi.json index 31e5dfad..7dc61780 100644 --- a/openapi.json +++ b/openapi.json @@ -1,6 +1,20 @@ { "components": { "schemas": { + "ActionDirection": { + "enum": [ + "east", + "west", + "north", + "south", + "north_east", + "north_west", + "south_east", + "south_west" + ], + "title": "ActionDirection", + "type": "string" + }, "ActionPresetOut": { "description": "动作预设响应。", "properties": { @@ -117,6 +131,14 @@ "title": "Name", "type": "string" }, + "sequences": { + "description": "可选多方向源序列与镜像关系;旧数据的 frames 视为 east", + "items": { + "$ref": "#/components/schemas/CharacterActionSequence" + }, + "title": "Sequences", + "type": "array" + }, "type": { "description": "动作类型: idle / walk / attack / custom", "title": "Type", @@ -154,6 +176,10 @@ ], "title": "Custom Prompt" }, + "direction": { + "$ref": "#/components/schemas/ActionDirection", + "default": "east" + }, "loop": { "anyOf": [ { @@ -166,7 +192,7 @@ "title": "Loop" }, "num_frames": { - "default": 16, + "default": 32, "maximum": 64.0, "minimum": 1.0, "title": "Num Frames", @@ -236,6 +262,73 @@ "title": "CharacterActionGenerateRequest", "type": "object" }, + "CharacterActionSequence": { + "description": "一个动作的源方向帧或水平镜像关系。", + "properties": { + "direction": { + "enum": [ + "east", + "west", + "north", + "south", + "north_east", + "north_west", + "south_east", + "south_west" + ], + "title": "Direction", + "type": "string" + }, + "frame_count": { + "description": "该方向声明的帧数", + "minimum": 0.0, + "title": "Frame Count", + "type": "integer" + }, + "frames": { + "description": "该方向帧列表", + "items": { + "$ref": "#/components/schemas/CharacterFrame" + }, + "title": "Frames", + "type": "array" + }, + "mirror_x": { + "default": false, + "description": "是否水平镜像源方向", + "title": "Mirror X", + "type": "boolean" + }, + "source_direction": { + "anyOf": [ + { + "enum": [ + "east", + "west", + "north", + "south", + "north_east", + "north_west", + "south_east", + "south_west" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "description": "镜像方向引用的真实源方向", + "title": "Source Direction" + } + }, + "required": [ + "direction", + "frame_count" + ], + "title": "CharacterActionSequence", + "type": "object" + }, "CharacterCreate": { "description": "创建角色请求。", "properties": { @@ -295,7 +388,7 @@ "type": "object" }, "CharacterData": { - "description": "角色完整数据(造型→动作→帧)。", + "description": "角色完整数据(方向母版与造型→动作→帧)。", "properties": { "outfits": { "description": "造型列表", @@ -305,6 +398,14 @@ "title": "Outfits", "type": "array" }, + "templates": { + "description": "角色各源方向母版与镜像关系", + "items": { + "$ref": "#/components/schemas/CharacterTemplateSequence" + }, + "title": "Templates", + "type": "array" + }, "version": { "default": 1, "description": "结构版本", @@ -353,6 +454,10 @@ "CharacterImageGenerateRequest": { "description": "提交角色图片生成任务。", "properties": { + "direction": { + "$ref": "#/components/schemas/ActionDirection", + "default": "east" + }, "height": { "default": 1024, "maximum": 2048.0, @@ -366,9 +471,9 @@ "type": "string" }, "num_images": { - "default": 1, - "maximum": 4.0, - "minimum": 1.0, + "default": 2, + "maximum": 2.0, + "minimum": 2.0, "title": "Num Images", "type": "integer" }, @@ -550,6 +655,70 @@ "title": "CharacterStance", "type": "string" }, + "CharacterTemplateSequence": { + "description": "角色母版的真实源方向或水平镜像关系。", + "properties": { + "direction": { + "enum": [ + "east", + "west", + "north", + "south", + "north_east", + "north_west", + "south_east", + "south_west" + ], + "title": "Direction", + "type": "string" + }, + "image_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "真实源方向的母版 URL", + "title": "Image Url" + }, + "mirror_x": { + "default": false, + "description": "是否水平镜像源方向", + "title": "Mirror X", + "type": "boolean" + }, + "source_direction": { + "anyOf": [ + { + "enum": [ + "east", + "west", + "north", + "south", + "north_east", + "north_west", + "south_east", + "south_west" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "description": "镜像方向引用的真实源方向", + "title": "Source Direction" + } + }, + "required": [ + "direction" + ], + "title": "CharacterTemplateSequence", + "type": "object" + }, "CharacterUpdate": { "description": "更新角色请求——所有字段可选。", "properties": { From 34fdadb0a99c17e9d2bd49a35a1695601bdb02a3 Mon Sep 17 00:00:00 2001 From: xyh202131 <246811510+xyh202131@users.noreply.github.com> Date: Wed, 19 Aug 2026 21:45:39 +0800 Subject: [PATCH 02/15] fix(render3d): support one-way directional projects --- .../server/orchestrator/executor.py | 6 +++- .../tests/test_render3d_route_and_assets.py | 34 +++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/backend/packages/app/src/windup_app/server/orchestrator/executor.py b/backend/packages/app/src/windup_app/server/orchestrator/executor.py index aae5a10d..dc25d1a2 100644 --- a/backend/packages/app/src/windup_app/server/orchestrator/executor.py +++ b/backend/packages/app/src/windup_app/server/orchestrator/executor.py @@ -504,6 +504,10 @@ def _build_render3d(directions: int): from windup_ai_engine.strategy.base import DerivationStrategy from windup_common.models import GenRoute + # 项目可选单向(1/4/8),但 3D 出帧台的相机表只接受四向或八向。 + # 单向任务仍通过 ActionSpec.direction 只请求 east;这里的 4 只是底层表规格。 + renderer_directions = 4 if directions == 1 else directions + class _LazyRenderStrategy(DerivationStrategy): route = GenRoute.RENDER_3D @@ -519,7 +523,7 @@ def derive(self, card, action, source, progress): self._inner = RenderFrameStrategy( LocalSpriteRenderProvider(), - directions=directions, + directions=renderer_directions, ) return self._inner.derive(card, action, source, progress) diff --git a/backend/tests/test_render3d_route_and_assets.py b/backend/tests/test_render3d_route_and_assets.py index 7c146e4e..d09f43c5 100644 --- a/backend/tests/test_render3d_route_and_assets.py +++ b/backend/tests/test_render3d_route_and_assets.py @@ -414,6 +414,40 @@ def test_direction_task_renders_only_its_requested_3d_direction(): assert renderer.last_direction == "ne" +def test_one_way_3d_task_uses_a_valid_renderer_table(monkeypatch): + """单向项目仍只请求 east,但本地 3D 出帧台的方向表只能是四向或八向。""" + + class _OneWayRenderer: + def render( + self, + _rigged_model, + *, + directions=4, + frames=12, + direction=None, + **_kwargs, + ) -> SpriteSheet: + if directions not in (4, 8): + raise ValueError("出帧台方向数只能是 4 或 8") + if direction != "e": + raise ValueError(f"单向任务请求了错误朝向 {direction}") + return _sheet((direction,), frames) + + monkeypatch.setattr( + "windup_framework.providers.render3d.LocalSpriteRenderProvider", + _OneWayRenderer, + ) + + frames = ActionTaskExecutor._build_render3d(1).derive( + _card(), + _spec(direction=ActionDirection.EAST), + b"RIGGED", + _NullProgress(), + ) + + assert len(frames) == 4 + + def test_missing_direction_raises_instead_of_handing_back_another(): """出帧台没出请求的朝向就报错。换一个交出去 = 角色朝反方向走,而没有任何一道会红。""" renderer = _FakeRenderer(directions=("w", "s"), honor_requested=False) From 5b924146af52921d0b94c24061f51080b2492d7f Mon Sep 17 00:00:00 2001 From: xyh202131 <246811510+xyh202131@users.noreply.github.com> Date: Thu, 20 Aug 2026 12:24:53 +0800 Subject: [PATCH 03/15] feat(workflow): add directional generation flow --- .../app/src/windup_app/worker/handlers.py | 3 + backend/tests/test_mq_worker.py | 14 +- .../2026-08-20-directional-workflow-stage3.md | 106 +++ .../src/app/workflow-editor-route.test.tsx | 2 +- .../src/entities/character/directions.test.ts | 60 ++ frontend/src/entities/character/directions.ts | 72 ++ frontend/src/entities/character/index.test.ts | 316 ++++++++ frontend/src/entities/character/index.ts | 201 +++++ frontend/src/entities/generation/api.test.ts | 172 ++++- frontend/src/entities/generation/api.ts | 76 +- frontend/src/entities/generation/index.ts | 18 +- frontend/src/entities/index.ts | 17 +- frontend/src/entities/workflow-run/api.ts | 24 +- frontend/src/entities/workflow-run/index.ts | 8 +- .../features/workflow-controller/README.md | 2 +- .../workflow-controller/controller.test.ts | 531 ++++++++++++- .../workflow-controller/controller.ts | 715 ++++++++++++++---- frontend/src/pages/quick-start/index.test.tsx | 57 +- frontend/src/pages/quick-start/index.tsx | 117 ++- .../src/pages/quick-start/service.test.ts | 244 +++++- frontend/src/pages/quick-start/service.ts | 342 ++++++++- .../character-template-confirmation.ts | 50 +- .../src/pages/workflow-editor/index.test.tsx | 133 +++- frontend/src/pages/workflow-editor/index.tsx | 371 ++++++--- .../src/pages/workflow-editor/runtime.test.ts | 37 +- frontend/src/pages/workflow-editor/runtime.ts | 3 + .../use-workflow-editor-session.ts | 65 +- 27 files changed, 3317 insertions(+), 439 deletions(-) create mode 100644 docs/superpowers/plans/2026-08-20-directional-workflow-stage3.md create mode 100644 frontend/src/entities/character/directions.test.ts create mode 100644 frontend/src/entities/character/directions.ts diff --git a/backend/packages/app/src/windup_app/worker/handlers.py b/backend/packages/app/src/windup_app/worker/handlers.py index 3e1a1a9f..01eedb4a 100644 --- a/backend/packages/app/src/windup_app/worker/handlers.py +++ b/backend/packages/app/src/windup_app/worker/handlers.py @@ -21,6 +21,7 @@ GenerationType, TaskStatus, ) +from windup_common.directions import ActionDirection from windup_app.server.user.service import VERIFY_CODE_KEY from windup_framework.db.redis import get_redis from windup_framework.db.session import SessionLocal @@ -68,6 +69,7 @@ def _image_input(payload: dict) -> CharacterImageInput: width=int(payload.get("width") or 1024), height=int(payload.get("height") or 1024), num_images=int(payload.get("num_images") or 1), + direction=ActionDirection(payload.get("direction") or ActionDirection.EAST.value), ) @@ -85,6 +87,7 @@ def _action_input(payload: dict) -> CharacterActionInput: video_model=payload.get("video_model"), outfit_id=payload.get("outfit_id"), model_3d_url=payload.get("model_3d_url"), + direction=ActionDirection(payload.get("direction") or ActionDirection.EAST.value), ) diff --git a/backend/tests/test_mq_worker.py b/backend/tests/test_mq_worker.py index 0f7bc3f7..59013450 100644 --- a/backend/tests/test_mq_worker.py +++ b/backend/tests/test_mq_worker.py @@ -34,6 +34,7 @@ handle_verification_code, ) from windup_app.worker.pending_timeout import release_stale_pending_tasks +from windup_common.directions import ActionDirection from windup_framework.db.base import Base from windup_framework.mq.config import MAX_CONSUME_ATTEMPTS from windup_framework.mq.model import MqMessage @@ -138,7 +139,9 @@ def test_handle_generation_dispatches_image_task(db_session, engine, monkeypatch db_session, user_id=1, project_id=1, - input=CharacterImageInput(prompt="hero", width=512, height=512), + input=CharacterImageInput( + prompt="hero", width=512, height=512, direction=ActionDirection.NORTH + ), ) db_session.commit() @@ -150,6 +153,7 @@ def test_handle_generation_dispatches_image_task(db_session, engine, monkeypatch ) run_image.assert_called_once() assert run_image.call_args.args[0] == task.id + assert run_image.call_args.args[1].direction is ActionDirection.NORTH def test_dispatch_handler_unknown_type_raises(): @@ -400,7 +404,12 @@ def test_handle_generation_dispatches_action_task(db_session, engine, monkeypatc db_session, user_id=1, project_id=1, - input=CharacterActionInput(character_id=1, action_type=ActionType.WALK, num_frames=4), + input=CharacterActionInput( + character_id=1, + action_type=ActionType.WALK, + num_frames=4, + direction=ActionDirection.SOUTH, + ), ) db_session.commit() @@ -412,6 +421,7 @@ def test_handle_generation_dispatches_action_task(db_session, engine, monkeypatc ) run_action.assert_called_once() assert run_action.call_args.args[0] == task.id + assert run_action.call_args.args[1].direction is ActionDirection.SOUTH def test_handle_generation_unknown_type_raises(db_session, engine, monkeypatch): diff --git a/docs/superpowers/plans/2026-08-20-directional-workflow-stage3.md b/docs/superpowers/plans/2026-08-20-directional-workflow-stage3.md new file mode 100644 index 00000000..8499b853 --- /dev/null +++ b/docs/superpowers/plans/2026-08-20-directional-workflow-stage3.md @@ -0,0 +1,106 @@ +# Directional Workflow Stage 3 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** 在不改变工作流节点拓扑的前提下,让四向、八向项目在角色母版与动作首帧节点内按源方向独立生成、选择、恢复和重试,并让 Quick Start 与 Workflow Editor 共用同一份方向状态。 + +**Architecture:** 方向是节点内部子状态,不新增方向节点。前端从 Project.directionalMovement 推导真实源方向;每个 `WorkflowGenerationRef` 记录可选 `direction`,旧记录默认 east。Controller 以 `nodeId + role + direction` 隔离 GenerationTask,只有全部必需源方向完成并确认后节点才通过。Quick Start 与 Workflow Editor 都调用同一个 Controller,持久化到现有 WorkflowRun。 + +**Tech Stack:** TypeScript、React 19、Vite、Vitest、Testing Library、现有 REST/SSE Generation API。 + +**Spec:** `docs/superpowers/specs/2026-08-19-four-eight-direction-generation-design.md` + +## Global Constraints + +- 复用现有分支 `feat/directional-generation-backend`,不得创建新分支。 +- 只向 `xyh202131/Windup` 推送,并以 `1024XEngineer/Windup:main` 为 PR base。 +- 后端只修复 Worker 对既有 `direction` 字段的反序列化;不改 OpenAPI、导出、角色详情、发布门禁或 Playtest。 +- 每个真实源方向固定生成 2 个候选;旧无方向记录按 east 读取。 +- 所有测试先失败,再写最小实现;失败必须对应缺失的方向行为。 +- Git author、committer、PR head owner 均为 `xyh202131`,不得出现 Codex。 +- PR 描述与评审说明使用中文;PR 创建为 Ready for review,不自动合并。 + +### Task 1: 方向领域契约和 API 映射 + +**Files:** +- Create: `frontend/src/entities/character/directions.ts` +- Create: `frontend/src/entities/character/directions.test.ts` +- Modify: `frontend/src/entities/character/index.ts` +- Modify: `frontend/src/entities/character/index.test.ts` +- Modify: `frontend/src/entities/generation/index.ts` +- Modify: `frontend/src/entities/generation/api.ts` +- Modify: `frontend/src/entities/generation/api.test.ts` +- Modify: `frontend/src/entities/workflow-run/index.ts` +- Modify: `frontend/src/entities/workflow-run/api.ts` +- Modify: `frontend/src/entities/workflow-run/api.test.ts` +- Modify: `frontend/src/entities/index.ts` +- Modify: `backend/packages/app/src/windup_app/worker/handlers.py` +- Modify: `backend/tests/test_mq_worker.py` + +1. 写测试:single/four/eight 推导正确源方向;west 侧逻辑方向映射到对应 source direction。 +2. 运行方向测试并确认因缺少 helper/类型而失败。 +3. 写测试:图片任务创建 payload 带 direction、每方向只接受 2 个候选、旧 east 数据仍可解析。 +4. 运行 API 测试并确认失败原因是方向契约尚未实现。 +5. 最小实现 `ActionDirection`、方向 helper、Generation input/result direction 以及 `WorkflowGenerationRef.direction?`。 +6. 让 WorkflowRun 解析器接受方向选择映射并继续兼容旧单 URL 字段。 +7. 修复 MQ Worker 从任务 payload 恢复 direction,并验证非 east 输入不会退化为 east。 +8. 运行前端方向测试及 `uv run pytest tests/test_mq_worker.py -q`。 + +### Task 2: Controller 按方向隔离任务、恢复和重试 + +**Files:** +- Modify: `frontend/src/features/workflow-controller/controller.test.ts` +- Modify: `frontend/src/features/workflow-controller/controller.ts` +- Modify: `frontend/src/features/workflow-controller/README.md` + +1. 写测试:角色母版四向分别创建 task,task 引用包含方向,未全部完成时节点不能进入 selecting。 +2. 写测试:动作首帧按方向独立生成与确认,已完成方向不会被另一方向重试覆盖。 +3. 写测试:刷新恢复会逐方向读取任务并恢复订阅,单方向失败只标记该方向并可单独重试。 +4. 运行 controller 测试,确认新用例先失败。 +5. 将 generation 缓存、订阅和待挂载键改为 `nodeId + role + direction`;新增按方向查询接口。 +6. 实现方向批量提交、逐方向结果应用、选择映射持久化与全部方向完成门槛。 +7. 运行:`npm test -- --run src/features/workflow-controller/controller.test.ts`。 + +### Task 3: Quick Start 完成所有必需方向 + +**Files:** +- Modify: `frontend/src/pages/quick-start/service.test.ts` +- Modify: `frontend/src/pages/quick-start/service.ts` +- Modify: `frontend/src/pages/quick-start/index.test.tsx` +- Modify: `frontend/src/pages/quick-start/index.tsx` + +1. 写测试:服务读取项目 directionalMovement,并让角色母版、动作首帧生成覆盖全部源方向。 +2. 写测试:Quick Start 自动选择每方向候选并持久化到同一 Character/Outfit;页面展示当前方向进度。 +3. 写测试:恢复后仅继续未完成方向,已确认方向不重复提交。 +4. 运行 Quick Start 测试并确认失败。 +5. 最小实现方向会话状态、逐方向候选获取/确认和恢复流程。 +6. 运行:`npm test -- --run src/pages/quick-start/service.test.ts src/pages/quick-start/index.test.tsx`。 + +### Task 4: Workflow Editor 方向选择与单方向重试 + +**Files:** +- Modify: `frontend/src/pages/workflow-editor/character-template-confirmation.ts` +- Modify: `frontend/src/pages/workflow-editor/runtime.test.ts` +- Modify: `frontend/src/pages/workflow-editor/runtime.ts` +- Modify: `frontend/src/pages/workflow-editor/index.test.tsx` +- Modify: `frontend/src/pages/workflow-editor/index.tsx` +- Modify: `frontend/src/pages/workflow-editor/use-workflow-editor-session.ts` +- Modify: `frontend/src/pages/workflow-editor/workflow-editor-view.tsx` +- Modify: `frontend/src/pages/workflow-editor/workflow-editor.css` +- Modify: `frontend/src/app/workflow-editor-route.test.tsx` + +1. 写测试:运行时按方向恢复 candidate group,旧无方向引用落到 east。 +2. 写测试:编辑器显示源方向切换与每方向 2 个候选;确认、失败和重试只影响当前方向。 +3. 写测试:全部必需方向确认前不能推进下一节点。 +4. 运行 Workflow Editor 测试并确认失败。 +5. 最小实现方向 tabs、候选 group、方向状态提示、当前方向重试和确认。 +6. 运行:`npm test -- --run src/pages/workflow-editor/runtime.test.ts src/pages/workflow-editor/index.test.tsx src/app/workflow-editor-route.test.tsx`。 + +### Task 5: 范围清理、全量验证和 PR + +1. 检查 diff,确保后端仅含 Worker direction 修复及其测试,且没有 OpenAPI、export、character-detail、publish-gate、playtest 文件。 +2. 运行:`npm run format:check`、`npm run lint`、`npm run typecheck`、`npm test`、`npm run build`(目录 `frontend`)。 +3. 运行 `git diff --check`,检查提交作者、提交者和 message,不得出现 Codex。 +4. 提交到已有分支,推送到 `xyh202131/Windup`;推送前再次核对 remote 和 head owner。 +5. 创建中文 Ready for review PR,base 为 `1024XEngineer/Windup:main`,说明依赖 #449 且明确不包含后续阶段。 +6. 读取 PR 元数据,确认 head owner=`xyh202131`、base=`main`、状态 Ready、未合并。 diff --git a/frontend/src/app/workflow-editor-route.test.tsx b/frontend/src/app/workflow-editor-route.test.tsx index 24793b87..deaac953 100644 --- a/frontend/src/app/workflow-editor-route.test.tsx +++ b/frontend/src/app/workflow-editor-route.test.tsx @@ -36,4 +36,4 @@ it('只有进入 Workflow Editor 路由时才加载 React Flow 页面', async () expect(await screen.findByText('懒加载 Workflow Editor')).toBeTruthy() expect(pageModuleFactory).toHaveBeenCalledTimes(1) -}) +}, 30_000) diff --git a/frontend/src/entities/character/directions.test.ts b/frontend/src/entities/character/directions.test.ts new file mode 100644 index 00000000..ff79b690 --- /dev/null +++ b/frontend/src/entities/character/directions.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest' + +import { getDirectionProfile, resolveActionDirection, type ActionDirection } from './directions' + +describe('direction profiles', () => { + it.each([ + { + movement: 'single' as const, + sourceDirections: ['east'], + logicalDirections: ['east', 'west'], + }, + { + movement: 'four-way' as const, + sourceDirections: ['east', 'north', 'south'], + logicalDirections: ['east', 'west', 'north', 'south'], + }, + { + movement: 'eight-way' as const, + sourceDirections: ['east', 'north', 'south', 'north_east', 'south_east'], + logicalDirections: [ + 'east', + 'west', + 'north', + 'south', + 'north_east', + 'north_west', + 'south_east', + 'south_west', + ], + }, + ])('exposes the required $movement source and logical directions', (expected) => { + expect(getDirectionProfile(expected.movement)).toEqual({ + sourceDirections: expected.sourceDirections, + logicalDirections: expected.logicalDirections, + }) + }) + + it.each([ + ['west', 'east'], + ['north_west', 'north_east'], + ['south_west', 'south_east'], + ] as const)('resolves mirrored %s frames from %s', (direction, sourceDirection) => { + expect(resolveActionDirection(direction)).toEqual({ + direction, + sourceDirection, + mirrorX: true, + }) + }) + + it.each(['east', 'north', 'south', 'north_east', 'south_east'] satisfies ActionDirection[])( + 'keeps source direction %s independent', + (direction) => { + expect(resolveActionDirection(direction)).toEqual({ + direction, + sourceDirection: direction, + mirrorX: false, + }) + }, + ) +}) diff --git a/frontend/src/entities/character/directions.ts b/frontend/src/entities/character/directions.ts new file mode 100644 index 00000000..e0588955 --- /dev/null +++ b/frontend/src/entities/character/directions.ts @@ -0,0 +1,72 @@ +import type { DirectionalMovement } from '../project' + +export const ACTION_DIRECTIONS = [ + 'east', + 'west', + 'north', + 'south', + 'north_east', + 'north_west', + 'south_east', + 'south_west', +] as const + +export type ActionDirection = (typeof ACTION_DIRECTIONS)[number] + +export interface DirectionProfile { + readonly sourceDirections: readonly ActionDirection[] + readonly logicalDirections: readonly ActionDirection[] +} + +export interface ResolvedActionDirection { + readonly direction: ActionDirection + readonly sourceDirection: ActionDirection + readonly mirrorX: boolean +} + +const DIRECTION_PROFILES: Record = { + single: { + sourceDirections: ['east'], + logicalDirections: ['east', 'west'], + }, + 'four-way': { + sourceDirections: ['east', 'north', 'south'], + logicalDirections: ['east', 'west', 'north', 'south'], + }, + 'eight-way': { + sourceDirections: ['east', 'north', 'south', 'north_east', 'south_east'], + logicalDirections: [ + 'east', + 'west', + 'north', + 'south', + 'north_east', + 'north_west', + 'south_east', + 'south_west', + ], + }, +} + +const MIRROR_SOURCES: Partial> = { + west: 'east', + north_west: 'north_east', + south_west: 'south_east', +} + +export function getDirectionProfile(movement: DirectionalMovement): DirectionProfile { + return DIRECTION_PROFILES[movement] +} + +export function isActionDirection(value: unknown): value is ActionDirection { + return typeof value === 'string' && ACTION_DIRECTIONS.includes(value as ActionDirection) +} + +export function resolveActionDirection(direction: ActionDirection): ResolvedActionDirection { + const sourceDirection = MIRROR_SOURCES[direction] + return { + direction, + sourceDirection: sourceDirection ?? direction, + mirrorX: sourceDirection !== undefined, + } +} diff --git a/frontend/src/entities/character/index.test.ts b/frontend/src/entities/character/index.test.ts index 93703eb3..4b1e71b6 100644 --- a/frontend/src/entities/character/index.test.ts +++ b/frontend/src/entities/character/index.test.ts @@ -9,6 +9,26 @@ const characterDto = { reference_image_url: 'https://cdn.windup.test/reference.png', character_data: { version: 2, + templates: [ + { + direction: 'east', + source_direction: null, + mirror_x: false, + image_url: 'https://cdn.windup.test/reference.png', + }, + { + direction: 'west', + source_direction: 'east', + mirror_x: true, + image_url: null, + }, + { + direction: 'north', + source_direction: null, + mirror_x: false, + image_url: 'https://cdn.windup.test/reference-north.png', + }, + ], outfits: [ { id: 'outfit-default', @@ -112,6 +132,26 @@ describe('characterApis', () => { referenceImageUrl: 'https://cdn.windup.test/reference.png', dataVersion: 2, status: 1, + templates: [ + { + direction: 'east', + sourceDirection: null, + mirrorX: false, + imageUrl: 'https://cdn.windup.test/reference.png', + }, + { + direction: 'west', + sourceDirection: 'east', + mirrorX: true, + imageUrl: null, + }, + { + direction: 'north', + sourceDirection: null, + mirrorX: false, + imageUrl: 'https://cdn.windup.test/reference-north.png', + }, + ], outfits: [ { id: 'outfit-default', @@ -250,6 +290,7 @@ describe('characterApis', () => { reference_image_url: 'https://cdn.windup.test/reference.png', character_data: { version: 2, + templates: characterDto.character_data.templates, outfits: characterDto.character_data.outfits, }, }) @@ -271,6 +312,281 @@ describe('characterApis', () => { expect(character.outfits[0]?.model3dUrl).toBeNull() }) + it('preserves directional action sequences across GET and PATCH', async () => { + let request: Request | undefined + const directionalDto = structuredClone(characterDto) + const directionalSequences = [ + { + direction: 'east', + source_direction: null, + mirror_x: false, + frame_count: 1, + frames: [ + { + index: 0, + image_url: 'https://cdn.windup.test/walk-east-01.png', + duration_ms: 100, + }, + ], + }, + { + direction: 'west', + source_direction: 'east', + mirror_x: true, + frame_count: 1, + frames: [], + }, + ] + Object.assign(directionalDto.character_data.outfits[0]!.actions[0]!, { + sequences: directionalSequences, + }) + const characterApis = await loadCharacterApis(async (input, init) => { + request = new Request(input, init) + return jsonResponse(directionalDto) + }) + + const character = await characterApis.get('51') + expect(character.outfits[0]?.actions[0]?.sequences).toEqual([ + { + direction: 'east', + sourceDirection: null, + mirrorX: false, + frameCount: 1, + frames: [ + { + index: 0, + imageUrl: 'https://cdn.windup.test/walk-east-01.png', + durationMs: 100, + }, + ], + }, + { + direction: 'west', + sourceDirection: 'east', + mirrorX: true, + frameCount: 1, + frames: [], + }, + ]) + + await characterApis.update(character) + await expect(request?.json()).resolves.toMatchObject({ + character_data: { + outfits: [ + { + actions: [ + { + sequences: directionalSequences, + }, + ], + }, + ], + }, + }) + }) + + it('rejects a directional payload that mirrors north from south', async () => { + const invalidDto = structuredClone(characterDto) + Object.assign(invalidDto.character_data.outfits[0]!.actions[0]!, { + sequences: [ + { + direction: 'south', + source_direction: null, + mirror_x: false, + frame_count: 1, + frames: [ + { + index: 0, + image_url: 'https://cdn.windup.test/walk-south-01.png', + duration_ms: 100, + }, + ], + }, + { + direction: 'north', + source_direction: 'south', + mirror_x: true, + frame_count: 1, + frames: [], + }, + ], + }) + const characterApis = await loadCharacterApis(async () => jsonResponse(invalidDto)) + + await expect(characterApis.get('51')).rejects.toThrow('动作方向镜像关系无效') + }) + + it('rejects source direction frames that do not match the declared count', async () => { + const invalidDto = structuredClone(characterDto) + Object.assign(invalidDto.character_data.outfits[0]!.actions[0]!, { + sequences: [ + { + direction: 'east', + source_direction: null, + mirror_x: false, + frame_count: 2, + frames: [ + { + index: 1, + image_url: 'https://cdn.windup.test/walk-east-02.png', + duration_ms: 100, + }, + ], + }, + ], + }) + const characterApis = await loadCharacterApis(async () => jsonResponse(invalidDto)) + + await expect(characterApis.get('51')).rejects.toThrow('源动作方向帧无效') + }) + + it('rejects a mirrored direction whose frame count differs from its source', async () => { + const invalidDto = structuredClone(characterDto) + Object.assign(invalidDto.character_data.outfits[0]!.actions[0]!, { + sequences: [ + { + direction: 'east', + source_direction: null, + mirror_x: false, + frame_count: 1, + frames: [ + { + index: 0, + image_url: 'https://cdn.windup.test/walk-east-01.png', + duration_ms: 100, + }, + ], + }, + { + direction: 'west', + source_direction: 'east', + mirror_x: true, + frame_count: 2, + frames: [], + }, + ], + }) + const characterApis = await loadCharacterApis(async () => jsonResponse(invalidDto)) + + await expect(characterApis.get('51')).rejects.toThrow('镜像动作方向帧数与源方向不一致') + }) + + it('rejects invalid, inconsistent, and source-less character templates', async () => { + const east = { + direction: 'east', + source_direction: null, + mirror_x: false, + image_url: 'https://cdn.windup.test/reference.png', + } + const invalidCases = [ + { + templates: [{ ...east, direction: 'up' }], + message: '角色母版方向无效或重复', + }, + { + templates: [{ ...east, direction: 'west' }], + message: '角色母版方向镜像关系无效', + }, + { + templates: [{ ...east, image_url: ' ' }], + message: '真实源方向缺少角色母版图片', + }, + { + templates: [ + east, + { + direction: 'west', + source_direction: 'east', + mirror_x: true, + image_url: 'https://cdn.windup.test/west.png', + }, + ], + message: '角色母版图片与方向类型不匹配', + }, + { + templates: [ + { + direction: 'west', + source_direction: 'east', + mirror_x: true, + image_url: null, + }, + ], + message: '镜像角色母版缺少真实源方向', + }, + ] + + for (const invalidCase of invalidCases) { + const invalidDto = structuredClone(characterDto) + invalidDto.character_data.templates = + invalidCase.templates as unknown as typeof invalidDto.character_data.templates + const characterApis = await loadCharacterApis(async () => jsonResponse(invalidDto)) + + await expect(characterApis.get('51')).rejects.toThrow(invalidCase.message) + } + }) + + it('rejects unknown, duplicate, frame-owning, and source-less mirror directions', async () => { + const east = { + direction: 'east', + source_direction: null, + mirror_x: false, + frame_count: 1, + frames: [ + { + index: 0, + image_url: 'https://cdn.windup.test/walk-east-01.png', + duration_ms: 100, + }, + ], + } + const invalidCases = [ + { + sequences: [{ ...east, direction: 'up_left' }], + message: '动作方向无效或重复', + }, + { + sequences: [east, { ...east }], + message: '动作方向无效或重复', + }, + { + sequences: [ + east, + { + direction: 'west', + source_direction: 'east', + mirror_x: true, + frame_count: 1, + frames: east.frames, + }, + ], + message: '镜像动作方向不能保存独立帧', + }, + { + sequences: [ + { + direction: 'west', + source_direction: 'east', + mirror_x: true, + frame_count: 1, + frames: [], + }, + ], + message: '镜像动作方向缺少源方向', + }, + ] + + for (const invalidCase of invalidCases) { + const invalidDto = structuredClone(characterDto) + Object.assign(invalidDto.character_data.outfits[0]!.actions[0]!, { + sequences: invalidCase.sequences, + }) + const characterApis = await loadCharacterApis(async () => jsonResponse(invalidDto)) + + await expect(characterApis.get('51')).rejects.toThrow(invalidCase.message) + } + }) + it('deletes one Character through the backend resource path', async () => { let request: Request | undefined const characterApis = await loadCharacterApis(async (input, init) => { diff --git a/frontend/src/entities/character/index.ts b/frontend/src/entities/character/index.ts index 03db96ef..9a42b22c 100644 --- a/frontend/src/entities/character/index.ts +++ b/frontend/src/entities/character/index.ts @@ -1,6 +1,15 @@ import { createApiClient, getApiAccessToken } from '@/shared/api' import type { Paged, PageQuery } from '@/shared/pagination' +import { + ACTION_DIRECTIONS, + isActionDirection, + resolveActionDirection, + type ActionDirection, +} from './directions' + +export type { ActionDirection } from './directions' + /** PR #75 将动作类型定义为字符串;已知类型之外的后端扩展也应原样保留。 */ export type ActionType = string @@ -23,6 +32,22 @@ export interface Frame { durationMs: number | null } +export interface ActionSequence { + readonly direction: ActionDirection + readonly sourceDirection: ActionDirection | null + readonly mirrorX: boolean + readonly frameCount: number + readonly frames: Frame[] +} + +/** Character 级母版;真实源方向保存图片,镜像方向只保存来源关系。 */ +export interface CharacterTemplate { + readonly direction: ActionDirection + readonly sourceDirection: ActionDirection | null + readonly mirrorX: boolean + readonly imageUrl: string | null +} + export interface Action { /** Action 只在所属 Outfit 内唯一。 */ id: string @@ -33,6 +58,8 @@ export interface Action { fps: number frameCount: number frames: Frame[] + /** 可选多方向序列;旧资产的顶层 frames 在单向项目中视为 east。 */ + sequences?: ActionSequence[] } export interface Outfit { @@ -56,6 +83,8 @@ export interface Character { name: string | null description: string | null referenceImageUrl: string | null + /** 后续新增动作时恢复各方向输入,避免用 east 母版生成其他朝向。 */ + templates?: CharacterTemplate[] /** character_data.version,更新整棵资产树时必须原样带回。 */ dataVersion: number status: CharacterStatus @@ -94,6 +123,21 @@ interface CharacterFrameDto { duration_ms: number | null } +interface CharacterActionSequenceDto { + direction: unknown + source_direction: unknown + mirror_x: unknown + frame_count: number + frames: CharacterFrameDto[] +} + +interface CharacterTemplateDto { + direction: unknown + source_direction: unknown + mirror_x: unknown + image_url: unknown +} + interface CharacterActionDto { id: string type: string @@ -102,6 +146,7 @@ interface CharacterActionDto { fps: number frame_count: number frames: CharacterFrameDto[] + sequences?: CharacterActionSequenceDto[] } interface CharacterOutfitDto { @@ -115,6 +160,7 @@ interface CharacterOutfitDto { interface CharacterDataDto { version: number + templates?: CharacterTemplateDto[] outfits: CharacterOutfitDto[] } @@ -148,6 +194,138 @@ function mapFrame(dto: CharacterFrameDto): Frame { } } +function mapActionSequences(dtos: CharacterActionSequenceDto[]): ActionSequence[] { + const directions = new Set() + const sequences = dtos.map((dto) => { + if (!isActionDirection(dto.direction) || directions.has(dto.direction)) { + throw new TypeError('动作方向无效或重复') + } + directions.add(dto.direction) + const resolution = resolveActionDirection(dto.direction) + const sourceDirection = dto.source_direction + if ( + typeof dto.mirror_x !== 'boolean' || + (sourceDirection !== null && !isActionDirection(sourceDirection)) || + dto.mirror_x !== resolution.mirrorX || + sourceDirection !== (resolution.mirrorX ? resolution.sourceDirection : null) + ) { + throw new TypeError('动作方向镜像关系无效') + } + if (dto.mirror_x && dto.frames.length > 0) { + throw new TypeError('镜像动作方向不能保存独立帧') + } + if ( + !dto.mirror_x && + (dto.frame_count <= 0 || + dto.frames.length !== dto.frame_count || + dto.frames + .map((frame) => frame.index) + .sort((left, right) => left - right) + .some((index, expected) => index !== expected)) + ) { + throw new TypeError('源动作方向帧无效') + } + return { + direction: dto.direction, + sourceDirection, + mirrorX: dto.mirror_x, + frameCount: dto.frame_count, + frames: dto.frames.map(mapFrame), + } + }) + + const byDirection = new Map(sequences.map((sequence) => [sequence.direction, sequence])) + for (const sequence of sequences) { + if (sequence.sourceDirection === null) continue + const source = byDirection.get(sequence.sourceDirection) + if (source === undefined || source.sourceDirection !== null || source.mirrorX) { + throw new TypeError('镜像动作方向缺少源方向') + } + if (sequence.frameCount !== source.frameCount) { + throw new TypeError('镜像动作方向帧数与源方向不一致') + } + } + return sequences +} + +function mapCharacterTemplates(dtos: CharacterTemplateDto[]): CharacterTemplate[] { + const directions = new Set() + const templates = dtos.map((dto) => { + if (!isActionDirection(dto.direction) || directions.has(dto.direction)) { + throw new TypeError('角色母版方向无效或重复') + } + directions.add(dto.direction) + const resolution = resolveActionDirection(dto.direction) + const sourceDirection = dto.source_direction + if ( + typeof dto.mirror_x !== 'boolean' || + (sourceDirection !== null && !isActionDirection(sourceDirection)) || + dto.mirror_x !== resolution.mirrorX || + sourceDirection !== (resolution.mirrorX ? resolution.sourceDirection : null) + ) { + throw new TypeError('角色母版方向镜像关系无效') + } + if (dto.mirror_x ? dto.image_url !== null : typeof dto.image_url !== 'string') { + throw new TypeError('角色母版图片与方向类型不匹配') + } + const imageUrl = typeof dto.image_url === 'string' ? dto.image_url.trim() : null + if (!dto.mirror_x && !imageUrl) throw new TypeError('真实源方向缺少角色母版图片') + return { + direction: dto.direction, + sourceDirection, + mirrorX: dto.mirror_x, + imageUrl, + } + }) + + const byDirection = new Map(templates.map((template) => [template.direction, template])) + for (const template of templates) { + if (template.sourceDirection === null) continue + const source = byDirection.get(template.sourceDirection) + if ( + source === undefined || + source.sourceDirection !== null || + source.mirrorX || + !source.imageUrl + ) { + throw new TypeError('镜像角色母版缺少真实源方向') + } + } + return templates +} + +/** 将 WorkflowRun 选中的真实源图转成可持久化的完整方向关系。 */ +export function characterTemplatesFromImages( + images: Partial>, +): CharacterTemplate[] { + return ACTION_DIRECTIONS.flatMap((direction) => { + const resolution = resolveActionDirection(direction) + const imageUrl = images[resolution.sourceDirection]?.trim() + if (!imageUrl) return [] + return [ + { + direction, + sourceDirection: resolution.mirrorX ? resolution.sourceDirection : null, + mirrorX: resolution.mirrorX, + imageUrl: resolution.mirrorX ? null : imageUrl, + }, + ] + }) +} + +/** 只还原真实源图;镜像方向由消费方根据关系生成。 */ +export function characterTemplateImages( + templates: readonly CharacterTemplate[] = [], +): Partial> { + return Object.fromEntries( + templates.flatMap((template) => + template.sourceDirection === null && !template.mirrorX && template.imageUrl + ? [[template.direction, template.imageUrl]] + : [], + ), + ) +} + function mapAction(dto: CharacterActionDto, outfitId: string): Action { return { id: dto.id, @@ -158,6 +336,11 @@ function mapAction(dto: CharacterActionDto, outfitId: string): Action { fps: dto.fps, frameCount: dto.frame_count, frames: dto.frames.map(mapFrame), + ...(dto.sequences === undefined + ? {} + : { + sequences: mapActionSequences(dto.sequences), + }), } } @@ -182,6 +365,7 @@ function mapCharacter(dto: CharacterDto): Character { name: dto.name, description: dto.description, referenceImageUrl: dto.reference_image_url, + templates: mapCharacterTemplates(dto.character_data.templates ?? []), dataVersion: dto.character_data.version, status: mapCharacterStatus(dto.status), outfits: dto.character_data.outfits.map((outfit) => mapOutfit(outfit, characterId)), @@ -205,6 +389,17 @@ function toActionDto(action: Action): CharacterActionDto { fps: action.fps, frame_count: action.frameCount, frames: action.frames.map(toFrameDto), + ...(action.sequences === undefined + ? {} + : { + sequences: action.sequences.map((sequence) => ({ + direction: sequence.direction, + source_direction: sequence.sourceDirection, + mirror_x: sequence.mirrorX, + frame_count: sequence.frameCount, + frames: sequence.frames.map(toFrameDto), + })), + }), } } @@ -268,6 +463,12 @@ export const characterApis: CharacterApis = { reference_image_url: character.referenceImageUrl, character_data: { version: character.dataVersion, + templates: (character.templates ?? []).map((template) => ({ + direction: template.direction, + source_direction: template.sourceDirection, + mirror_x: template.mirrorX, + image_url: template.imageUrl, + })), outfits: character.outfits.map(toOutfitDto), }, }, diff --git a/frontend/src/entities/generation/api.test.ts b/frontend/src/entities/generation/api.test.ts index b7897d7f..9b5bcd3b 100644 --- a/frontend/src/entities/generation/api.test.ts +++ b/frontend/src/entities/generation/api.test.ts @@ -24,14 +24,11 @@ function taskData(overrides: Record = {}) { project_id: 42, task_type: 'character_image', status: 'completed', - input_payload: { num_images: 3 }, + input_payload: { num_images: 2, direction: 'east' }, result: { type: 'character_image', - image_urls: [ - 'https://cdn.test/candidate-1.png', - 'https://cdn.test/candidate-2.png', - 'https://cdn.test/candidate-3.png', - ], + direction: 'east', + image_urls: ['https://cdn.test/candidate-1.png', 'https://cdn.test/candidate-2.png'], }, error_message: null, ...overrides, @@ -77,8 +74,19 @@ describe('createGenerationApis', () => { } }) - it('固定请求并映射三张角色母版候选', async () => { - const request = vi.fn(async (_url: string, _init?: RequestInit) => success(taskData())) + it('固定请求并映射两张角色母版候选', async () => { + const request = vi.fn(async (_url: string, _init?: RequestInit) => + success( + taskData({ + input_payload: { num_images: 2, direction: 'north_east' }, + result: { + type: 'character_image', + direction: 'north_east', + image_urls: ['https://cdn.test/candidate-1.png', 'https://cdn.test/candidate-2.png'], + }, + }), + ), + ) const stream = vi.fn(() => vi.fn()) const apis = createGenerationApis({ baseUrl: 'https://api.test/', @@ -92,6 +100,7 @@ describe('createGenerationApis', () => { prompt: 'pixel hero', spriteWidth: 64, spriteHeight: 96, + direction: 'north_east', }) expect(request).toHaveBeenCalledWith( @@ -106,22 +115,34 @@ describe('createGenerationApis', () => { negative_prompt: '', width: 64, height: 96, - num_images: 3, + num_images: 2, + direction: 'north_east', }), }), ) expect(generation.result).toEqual({ type: 'character_template', + direction: 'north_east', images: [ { url: 'https://cdn.test/candidate-1.png' }, { url: 'https://cdn.test/candidate-2.png' }, - { url: 'https://cdn.test/candidate-3.png' }, ], }) }) - it('根据角色母版和动作提示词生成三张动作首帧候选', async () => { - const request = vi.fn(async (_url: string, _init?: RequestInit) => success(taskData())) + it('根据角色母版和动作提示词生成两张动作首帧候选', async () => { + const request = vi.fn(async (_url: string, _init?: RequestInit) => + success( + taskData({ + input_payload: { num_images: 2, direction: 'south' }, + result: { + type: 'character_image', + direction: 'south', + image_urls: ['https://cdn.test/candidate-1.png', 'https://cdn.test/candidate-2.png'], + }, + }), + ), + ) const apis = createGenerationApis({ baseUrl: '', transport: { request, stream: vi.fn(() => vi.fn()) }, @@ -135,6 +156,7 @@ describe('createGenerationApis', () => { referenceMedia: [reference('https://cdn.test/template.png')], spriteWidth: 64, spriteHeight: 96, + direction: 'south', }) expect(request.mock.calls[0]?.[0]).toBe('/generation/image') @@ -145,14 +167,15 @@ describe('createGenerationApis', () => { negative_prompt: '', width: 64, height: 96, - num_images: 3, + num_images: 2, + direction: 'south', }) expect(generation.result).toEqual({ type: 'first_frame', + direction: 'south', images: [ { url: 'https://cdn.test/candidate-1.png' }, { url: 'https://cdn.test/candidate-2.png' }, - { url: 'https://cdn.test/candidate-3.png' }, ], }) }) @@ -216,6 +239,7 @@ describe('createGenerationApis', () => { reference_image_urls: ['https://cdn.test/frame-1.png', 'https://cdn.test/extra.png'], num_frames: 32, outfit_id: 'default', + direction: 'east', }) expect(generation.result).toEqual({ type: 'complete_animation', @@ -312,6 +336,116 @@ describe('createGenerationApis', () => { expect(request).not.toHaveBeenCalled() }) + it('保留多方向完整动画任务的方向约束', async () => { + const request = vi.fn(async () => + success( + taskData({ + task_type: 'character_action', + input_payload: { num_frames: 32, action_type: 'walk', direction: 'north' }, + result: { + type: 'character_action', + action_type: 'walk', + direction: 'north', + frames: actionFrames(32), + }, + }), + ), + ) + const apis = createGenerationApis({ + transport: { request, stream: vi.fn(() => vi.fn()) }, + }) + + const generation = await apis.create({ + type: 'complete_animation', + projectId: '42', + characterId: '5', + outfitId: 'default', + method: 'video-cropping', + actionType: 'walk', + firstFrameUrl: 'https://cdn.test/frame-1.png', + prompt: 'move forward', + referenceMedia: [], + direction: 'north', + }) + + expect(generation.result).toMatchObject({ type: 'complete_animation', direction: 'north' }) + }) + + it('拒绝任务输入或结果偷换已请求的方向', async () => { + const imageResultMismatch = taskData({ + result: { + type: 'character_image', + direction: 'north', + image_urls: ['north-1.png', 'north-2.png'], + }, + }) + const actionResultMismatch = taskData({ + task_type: 'character_action', + input_payload: { num_frames: 32, action_type: 'walk', direction: 'east' }, + result: { + type: 'character_action', + action_type: 'walk', + direction: 'north', + frames: actionFrames(32), + }, + }) + const imageInputMismatch = taskData({ + input_payload: { num_images: 2, direction: 'north' }, + result: { + type: 'character_image', + direction: 'north', + image_urls: ['north-1.png', 'north-2.png'], + }, + }) + const actionInputMismatch = taskData({ + task_type: 'character_action', + input_payload: { num_frames: 32, action_type: 'walk', direction: 'north' }, + result: { + type: 'character_action', + action_type: 'walk', + direction: 'north', + frames: actionFrames(32), + }, + }) + const request = vi + .fn() + .mockResolvedValueOnce(success(imageResultMismatch)) + .mockResolvedValueOnce(success(actionResultMismatch)) + .mockResolvedValueOnce(success(imageInputMismatch)) + .mockResolvedValueOnce(success(actionInputMismatch)) + .mockResolvedValueOnce( + success( + taskData({ + input_payload: { num_images: 2 }, + result: { + type: 'character_image', + image_urls: ['legacy-1.png', 'legacy-2.png'], + }, + }), + ), + ) + const apis = createGenerationApis({ + transport: { request, stream: vi.fn(() => vi.fn()) }, + }) + + await expect( + apis.get('42', '91', { type: 'character_template', direction: 'east' }), + ).rejects.toThrow('角色图片结果 direction 与请求不一致') + await expect( + apis.get('42', '91', { type: 'complete_animation', actionType: 'walk', direction: 'east' }), + ).rejects.toThrow('完整动画结果 direction 与请求不一致') + await expect( + apis.get('42', '91', { type: 'character_template', direction: 'east' }), + ).rejects.toThrow('生成任务 direction 与请求不一致') + await expect( + apis.get('42', '91', { type: 'complete_animation', actionType: 'walk', direction: 'east' }), + ).rejects.toThrow('生成任务 direction 与请求不一致') + await expect(apis.get('42', '91')).resolves.toMatchObject({ + type: 'character_template', + result: { type: 'character_template' }, + }) + }) + it('拒绝未知任务状态而不是默认为 pending', async () => { const request = vi.fn(async () => success(taskData({ status: 'queued' }))) const apis = createGenerationApis({ @@ -530,13 +664,13 @@ describe('createGenerationApis', () => { ['输入对象', { input_payload: [] }, '生成任务 input_payload 无效'], ['结果对象', { result: [] }, '生成任务 result 无效'], ['错误字段', { error_message: 1 }, '生成任务 error_message 无效'], - ['任务输入', { input_payload: { num_images: 4 } }, 'num_images 必须为 3'], + ['任务输入', { input_payload: { num_images: 4 } }, 'num_images 必须为 2'], [ '图片结果类型', { result: { type: 'video', image_urls: ['a', 'b', 'c'] } }, '角色图片结果 type 无效', ], - ['图片数量', { result: { type: 'character_image', image_urls: ['a'] } }, '必须包含 3 个候选'], + ['图片数量', { result: { type: 'character_image', image_urls: ['a'] } }, '必须包含 2 个候选'], ['完成结果', { result: null }, '完成任务缺少 result'], ])('校验%s', async (_label, overrides, message) => { const apis = createGenerationApis({ @@ -707,7 +841,7 @@ describe('createGenerationApis', () => { ) }) - it('按显式阶段恢复图片任务为三张动作首帧候选', async () => { + it('按显式阶段恢复图片任务为两张动作首帧候选', async () => { const request = vi.fn().mockResolvedValueOnce(success(taskData())) const apis = createGenerationApis({ transport: { request, stream: vi.fn(() => vi.fn()) }, @@ -722,13 +856,12 @@ describe('createGenerationApis', () => { images: [ { url: 'https://cdn.test/candidate-1.png' }, { url: 'https://cdn.test/candidate-2.png' }, - { url: 'https://cdn.test/candidate-3.png' }, ], }, }) }) - it('订阅图片任务时按首帧阶段映射三张候选', () => { + it('订阅图片任务时按首帧阶段映射两张候选', () => { let streamOptions: EventStreamOptions | undefined const onEvent = vi.fn() const apis = createGenerationApis({ @@ -753,7 +886,6 @@ describe('createGenerationApis', () => { images: [ { url: 'https://cdn.test/candidate-1.png' }, { url: 'https://cdn.test/candidate-2.png' }, - { url: 'https://cdn.test/candidate-3.png' }, ], }, error: null, diff --git a/frontend/src/entities/generation/api.ts b/frontend/src/entities/generation/api.ts index 69829715..99bb5358 100644 --- a/frontend/src/entities/generation/api.ts +++ b/frontend/src/entities/generation/api.ts @@ -22,6 +22,7 @@ import type { GenerationType, TaskStatus, } from '.' +import { isActionDirection, type ActionDirection } from '@/entities/character/directions' type RequestFunction = (url: string, init?: RequestInit) => Promise @@ -172,7 +173,15 @@ function expectedBackendType(type: GenerationType): BackendGenerationType { return type === 'complete_animation' ? 'character_action' : 'character_image' } -export const IMAGE_CANDIDATE_COUNT = 3 +export const IMAGE_CANDIDATE_COUNT = 2 + +const DEFAULT_DIRECTION: ActionDirection = 'east' + +function taskDirection(value: unknown, field: string): ActionDirection | undefined { + if (value === undefined || value === null) return undefined + if (!isActionDirection(value)) throw new GenerationApiError(`${field} 无效`, 200) + return value +} function nonEmptyString(value: unknown, field: string): string { if (typeof value !== 'string' || value.trim() === '') { @@ -188,6 +197,10 @@ function mapImageResult( if (result.type !== 'character_image') { throw new GenerationApiError('角色图片结果 type 无效', 200) } + const resultDirection = taskDirection(result.direction, '角色图片结果 direction') + if (expectation.direction !== undefined && resultDirection !== expectation.direction) { + throw new GenerationApiError('角色图片结果 direction 与请求不一致', 200) + } if ( !Array.isArray(result.image_urls) || result.image_urls.length === 0 || @@ -203,7 +216,9 @@ function mapImageResult( 200, ) } - return { type: expectation.type, images } + return expectation.direction === undefined + ? { type: expectation.type, images } + : { type: expectation.type, direction: expectation.direction, images } } function mapActionResult( @@ -213,6 +228,10 @@ function mapActionResult( if (result.type !== 'character_action') { throw new GenerationApiError('完整动画结果 type 无效', 200) } + const resultDirection = taskDirection(result.direction, '完整动画结果 direction') + if (expectation.direction !== undefined && resultDirection !== expectation.direction) { + throw new GenerationApiError('完整动画结果 direction 与请求不一致', 200) + } if (typeof result.action_type !== 'string' || !ACTION_TYPES.has(result.action_type)) { throw new GenerationApiError('完整动画结果 action_type 无效', 200) } @@ -258,10 +277,13 @@ function mapActionResult( throw new GenerationApiError('动作帧 index 必须从 0 开始连续排列', 200) } } - return { + const mapped = { type: 'complete_animation', frames: orderedFrames, - } + } as const + return expectation.direction === undefined + ? mapped + : { ...mapped, direction: expectation.direction } } function mapResult( @@ -307,6 +329,12 @@ function validateInputPayload( 200, ) } + if (expectation.direction !== undefined) { + const direction = taskDirection(inputPayload.direction, '生成任务 direction') + if (direction !== expectation.direction) { + throw new GenerationApiError('生成任务 direction 与请求不一致', 200) + } + } return } const expectedFrameCount = 32 @@ -319,10 +347,23 @@ function validateInputPayload( if (inputPayload.action_type !== expectation.actionType) { throw new GenerationApiError('动作任务 input_payload.action_type 与请求不一致', 200) } + if (expectation.direction !== undefined) { + const direction = taskDirection(inputPayload.direction, '生成任务 direction') + if (direction !== expectation.direction) { + throw new GenerationApiError('生成任务 direction 与请求不一致', 200) + } + } } function inferExpectation(dto: GenerationTaskDto): GenerationExpectation { - if (dto.taskType === 'character_image') return { type: 'character_template' } + const direction = dto.inputPayload + ? taskDirection(dto.inputPayload.direction, '生成任务 direction') + : undefined + if (dto.taskType === 'character_image') { + return direction === undefined + ? { type: 'character_template' } + : { type: 'character_template', direction } + } if (dto.inputPayload === null) { throw new GenerationApiError('动作任务缺少 input_payload', 200) } @@ -331,7 +372,9 @@ function inferExpectation(dto: GenerationTaskDto): GenerationExpectation { throw new GenerationApiError('动作任务 input_payload.action_type 无效', 200) } if (dto.inputPayload.num_frames === 32) { - return { type: 'complete_animation', actionType } + return direction === undefined + ? { type: 'complete_animation', actionType } + : { type: 'complete_animation', actionType, direction } } throw new GenerationApiError('动作任务 input_payload.num_frames 无法映射到前端阶段', 200) } @@ -506,7 +549,11 @@ export function createGenerationApis(config: GenerationApiConfig): GenerationApi if (referenceImageUrls.length === 0) { throw new GenerationApiError('这个造型还没有可用的角色母版,请先完成定妆再生成动作') } - const expectation = { type: input.type, actionType: input.actionType } as const + const expectation = { + type: input.type, + actionType: input.actionType, + ...(input.direction === undefined ? {} : { direction: input.direction }), + } as const const generation = await post('/generation/action', projectId, expectation, { project_id: projectId, character_id: inputPositiveInteger(input.characterId, 'characterId'), @@ -525,6 +572,7 @@ export function createGenerationApis(config: GenerationApiConfig): GenerationApi ...(input.method === '3d-to-2d' ? { outfit_id: nonEmptyString(input.outfitId, 'outfitId') } : {}), + direction: input.direction ?? DEFAULT_DIRECTION, }) expectations.set(generation.id, expectation) return generation as Generation @@ -532,8 +580,15 @@ export function createGenerationApis(config: GenerationApiConfig): GenerationApi const expectation = input.type === 'first_frame' - ? ({ type: 'first_frame', actionType: input.actionType } as const) - : ({ type: 'character_template' } as const) + ? ({ + type: 'first_frame', + actionType: input.actionType, + ...(input.direction === undefined ? {} : { direction: input.direction }), + } as const) + : ({ + type: 'character_template', + ...(input.direction === undefined ? {} : { direction: input.direction }), + } as const) const referenceImageUrl = input.referenceMedia[0] ? String(input.referenceMedia[0]) : null if (input.type === 'first_frame' && !referenceImageUrl) { throw new GenerationApiError('动作首帧生成必须提供已确认的角色母版') @@ -545,8 +600,9 @@ export function createGenerationApis(config: GenerationApiConfig): GenerationApi negative_prompt: '', width: inputPositiveInteger(input.spriteWidth, 'spriteWidth'), height: inputPositiveInteger(input.spriteHeight, 'spriteHeight'), - // 角色母版和动作首帧都由一次图片任务生成三张候选。 + // 角色母版和动作首帧都由一次图片任务生成两张候选。 num_images: IMAGE_CANDIDATE_COUNT, + direction: input.direction ?? DEFAULT_DIRECTION, }) expectations.set(generation.id, expectation) return generation as Generation diff --git a/frontend/src/entities/generation/index.ts b/frontend/src/entities/generation/index.ts index 9121160f..67a2af0a 100644 --- a/frontend/src/entities/generation/index.ts +++ b/frontend/src/entities/generation/index.ts @@ -1,4 +1,5 @@ import type { ActionType } from '../character' +import type { ActionDirection } from '../character' import type { MediaReference } from '../media' import type { ActionGenerationMethod } from '../workflow-run' @@ -26,9 +27,9 @@ export type TaskStatus = 'pending' | 'running' | 'completed' | 'failed' export type GenerationType = 'character_template' | 'first_frame' | 'complete_animation' export type GenerationExpectation = - | { type: 'character_template' } - | { type: 'first_frame'; actionType: ActionType } - | { type: 'complete_animation'; actionType: ActionType } + | { type: 'character_template'; direction?: ActionDirection } + | { type: 'first_frame'; actionType: ActionType; direction?: ActionDirection } + | { type: 'complete_animation'; actionType: ActionType; direction?: ActionDirection } interface GenerationInputBase { projectId: string @@ -44,6 +45,8 @@ export interface CharacterTemplateGenerationInput extends GenerationInputBase { /** 必须与 Project 的精灵尺寸一致,后端会在提交时校验。 */ spriteWidth: number spriteHeight: number + /** 当前任务生成的真实源方向;旧调用缺省时按 east 兼容。 */ + direction?: ActionDirection } /** 基于已确认角色母版生成动作首帧候选图。 */ @@ -55,6 +58,8 @@ export interface FirstFrameGenerationInput extends GenerationInputBase { /** 必须与 Project 的精灵尺寸一致,后端会在提交时校验。 */ spriteWidth: number spriteHeight: number + /** 首帧必须与角色母版使用同一个真实源方向。 */ + direction?: ActionDirection } /** 以已确认首帧为起点生成完整动画。 */ @@ -79,6 +84,8 @@ export interface CompleteAnimationGenerationInput extends GenerationInputBase { * 抽搐、产物不可用;反之只是不无缝闭环、产物仍可用。所以能给就给。 */ loop?: boolean + /** 完整动作的真实源方向;镜像方向不创建动画任务。 */ + direction?: ActionDirection } export type GenerationInput = @@ -100,17 +107,20 @@ export interface GeneratedFrame extends GeneratedImage { /** 结果按 type 分别定义,不共用一个 urls 数组。 */ export interface CharacterTemplateGenerationResult { type: 'character_template' + direction?: ActionDirection images: readonly GeneratedImage[] } export interface FirstFrameGenerationResult { type: 'first_frame' - /** 同一图片任务生成的三张动作首帧候选。 */ + /** 同一图片任务生成的两张动作首帧候选。 */ + direction?: ActionDirection images: readonly GeneratedImage[] } export interface CompleteAnimationGenerationResult { type: 'complete_animation' + direction?: ActionDirection frames: readonly GeneratedFrame[] } diff --git a/frontend/src/entities/index.ts b/frontend/src/entities/index.ts index 310906ea..1acec4ad 100644 --- a/frontend/src/entities/index.ts +++ b/frontend/src/entities/index.ts @@ -41,17 +41,32 @@ export type { /* 角色 —— 资产本体;造型、动作、帧都在这棵树里 */ export type { Action, + ActionDirection, + ActionSequence, ActionType, Character, CharacterApis, CharacterPageQuery, CharacterPublicationStatus, CharacterStatus, + CharacterTemplate, CreateCharacterInput, Frame, Outfit, } from './character' -export { CHARACTER_STATUS, characterApis } from './character' +export { + CHARACTER_STATUS, + characterApis, + characterTemplateImages, + characterTemplatesFromImages, +} from './character' +export { + ACTION_DIRECTIONS, + getDirectionProfile, + isActionDirection, + resolveActionDirection, +} from './character/directions' +export type { DirectionProfile, ResolvedActionDirection } from './character/directions' export { getOutfitPlayback } from './character/outfit-playback' /* 动作模板 —— 能跨角色复用的配方 */ diff --git a/frontend/src/entities/workflow-run/api.ts b/frontend/src/entities/workflow-run/api.ts index a9569c45..fcd942b2 100644 --- a/frontend/src/entities/workflow-run/api.ts +++ b/frontend/src/entities/workflow-run/api.ts @@ -16,6 +16,7 @@ import { WORKFLOW_NODE_STATUSES, WORKFLOW_RUN_STORAGE_STATUSES, } from './constants' +import { isActionDirection } from '../character/directions' /** 当前 WorkflowRun 已被其他请求更新,调用方需要重新读取后再继续修改。 */ export class WorkflowRunConflictError extends Error { @@ -50,7 +51,19 @@ function isGenerationRef(value: unknown): boolean { isRecord(value) && typeof value.taskId === 'string' && value.taskId.length > 0 && - isMember(value.role, WORKFLOW_GENERATION_ROLES) + isMember(value.role, WORKFLOW_GENERATION_ROLES) && + (value.direction === undefined || isActionDirection(value.direction)) + ) +} + +function isDirectionalSelectionMap(value: unknown): boolean { + return ( + value === undefined || + (isRecord(value) && + Object.entries(value).every( + ([direction, imageUrl]) => + isActionDirection(direction) && typeof imageUrl === 'string' && imageUrl.length > 0, + )) ) } @@ -143,8 +156,10 @@ function isCharacterTemplateNode(value: unknown): value is CharacterTemplateWork ['ready', 'generating', 'selecting', 'completed'].includes(String(value.phase)) && hasOnlyGenerationRole(value, 'character_template') && isNullableString(value.selectedImageUrl) && + isDirectionalSelectionMap(value.selectedImages) && (value.phase !== 'completed' || - (typeof value.selectedImageUrl === 'string' && value.selectedImageUrl.length > 0)) + (typeof value.selectedImageUrl === 'string' && value.selectedImageUrl.length > 0) || + (isRecord(value.selectedImages) && Object.keys(value.selectedImages).length > 0)) ) } @@ -157,8 +172,11 @@ function isActionFirstFrameNode(value: unknown): value is ActionFirstFrameWorkfl hasValidActionInput(value.input) && hasOnlyGenerationRole(value, 'first_frame') && isNullableString(value.selectedFirstFrameUrl) && + isDirectionalSelectionMap(value.selectedFirstFrameUrls) && (value.phase !== 'completed' || - (typeof value.selectedFirstFrameUrl === 'string' && value.selectedFirstFrameUrl.length > 0)) + (typeof value.selectedFirstFrameUrl === 'string' && value.selectedFirstFrameUrl.length > 0) || + (isRecord(value.selectedFirstFrameUrls) && + Object.keys(value.selectedFirstFrameUrls).length > 0)) ) } diff --git a/frontend/src/entities/workflow-run/index.ts b/frontend/src/entities/workflow-run/index.ts index c657e2d8..1036abc2 100644 --- a/frontend/src/entities/workflow-run/index.ts +++ b/frontend/src/entities/workflow-run/index.ts @@ -1,4 +1,4 @@ -import type { ActionType } from '../character' +import type { ActionDirection, ActionType } from '../character' import type { Generation } from '../generation' import type { MediaReference } from '../media' import type { Paged, PageQuery } from '@/shared/pagination' @@ -26,6 +26,8 @@ export type ActionGenerationMethod = 'video-cropping' | '3d-to-2d' export interface WorkflowGenerationRef { taskId: Generation['id'] role: WorkflowGenerationRole + /** 源方向任务;旧数据缺省时按 east 解释。镜像方向不会出现任务引用。 */ + direction?: ActionDirection } interface WorkflowNodeBase { @@ -71,6 +73,8 @@ export interface CharacterTemplateWorkflowNode extends WorkflowNodeBase { type: 'character-template' phase: 'ready' | 'generating' | 'selecting' | 'completed' selectedImageUrl: string | null + /** 各真实源方向最终确认的母版;selectedImageUrl 保留为 east 兼容字段。 */ + selectedImages?: Partial> } export interface WorkflowActionInput { @@ -87,6 +91,8 @@ export interface ActionFirstFrameWorkflowNode extends WorkflowNodeBase { phase: 'configuring' | 'generating' | 'selecting' | 'completed' input: WorkflowActionInput selectedFirstFrameUrl: string | null + /** 各真实源方向最终确认的首帧;selectedFirstFrameUrl 保留为 east 兼容字段。 */ + selectedFirstFrameUrls?: Partial> } /** 首帧确认后选择完整动画的生产路线。 */ diff --git a/frontend/src/features/workflow-controller/README.md b/frontend/src/features/workflow-controller/README.md index ed032420..2c370cee 100644 --- a/frontend/src/features/workflow-controller/README.md +++ b/frontend/src/features/workflow-controller/README.md @@ -34,7 +34,7 @@ async function generateCharacter() { - `entities/workflow-run` 定义纯数据和异步 CRUD,不包含推进方法。 - Controller 根据 `dependsOnNodeIds` 解锁节点,允许同一依赖下的多个 Action 并行。 - 新增 Action 一次创建动作首帧、动作生成方式、完整动画和审核四个 node,不会遗漏路线选择或用数组位置猜关系。 -- 动作首帧使用已确认角色母版和动作提示词调用图片 Generation,一次生成三张候选;用户确认其中一张后,完整动画节点才使用该首帧调用 32 帧动作 Generation。 +- 动作首帧按项目的真实源方向分别调用图片 Generation,每个方向生成两张候选;用户为全部真实方向各确认一张后,完整动画节点才使用对应首帧调用 32 帧动作 Generation。可水平镜像的方向只保存关系,不创建重复任务。 - Controller 方法与后端 Generation、WorkflowRun node 使用同一概念名:`characterTemplate`、`firstFrame`、`completeAnimation` 和 `review`,不再为同一概念保留另一套叫法。 - 当前视频裁剪路线继续调用既有 Generation;3D 转 2D 选择会随 WorkflowRun 落库,但接口提供前明确阻止生成。 - Generation 通过 `nodeId + taskId` 写回;节点重做后,旧任务的迟到结果会被丢弃。 diff --git a/frontend/src/features/workflow-controller/controller.test.ts b/frontend/src/features/workflow-controller/controller.test.ts index 2ec98d68..c7eeb1ce 100644 --- a/frontend/src/features/workflow-controller/controller.test.ts +++ b/frontend/src/features/workflow-controller/controller.test.ts @@ -10,6 +10,7 @@ import type { GenerationApis, GenerationEvent, ReviewWorkflowNode, + DirectionalMovement, WorkflowActionInput, WorkflowNode, WorkflowRun, @@ -219,7 +220,7 @@ function createGenerationHarness() { return { apis, emit, listeners, snapshots } } -function createController(run = createRun()) { +function createController(run = createRun(), directionalMovement: DirectionalMovement = 'single') { const workflow = createWorkflowApis(run) const generation = createGenerationHarness() const asyncErrors: Error[] = [] @@ -230,6 +231,7 @@ function createController(run = createRun()) { createId: () => 'action-created', now: () => '2026-08-09T00:00:00.000Z', onAsyncError: (error) => asyncErrors.push(error), + directionalMovement, }) return { controller, workflow, generation, asyncErrors } } @@ -375,11 +377,7 @@ describe('WorkflowController', () => { status: 'completed', result: { type: 'character_template', - images: [ - { url: 'https://img/knight-1.png' }, - { url: 'https://img/knight-2.png' }, - { url: 'https://img/knight-3.png' }, - ], + images: [{ url: 'https://img/knight-1.png' }, { url: 'https://img/knight-2.png' }], }, error: null, }) @@ -804,6 +802,7 @@ describe('WorkflowController', () => { referenceMedia: [], spriteWidth: 64, spriteHeight: 64, + direction: 'east', }) expect(workflow.getSaved().nodes).toEqual( expect.arrayContaining([ @@ -822,11 +821,7 @@ describe('WorkflowController', () => { status: 'completed', result: { type: 'character_template', - images: [ - { url: 'https://img/knight-1.png' }, - { url: 'https://img/knight-2.png' }, - { url: 'https://img/knight-3.png' }, - ], + images: [{ url: 'https://img/knight-1.png' }, { url: 'https://img/knight-2.png' }], }, error: null, }) @@ -841,6 +836,28 @@ describe('WorkflowController', () => { expect(asyncErrors).toEqual([]) }) + it('服务端回包已改变母版阶段时拒绝继续创建生成任务', async () => { + const { controller, workflow, generation } = createController() + const update = vi.mocked(workflow.apis.update) + const save = update.getMockImplementation()! + update.mockImplementationOnce(async (run) => { + const saved = await save(run) + return { + ...saved, + nodes: saved.nodes.map((node) => + node.id === 'template-1' && node.type === 'character-template' + ? { ...node, phase: 'selecting' as const } + : node, + ), + } + }) + + await expect( + controller.generateCharacterTemplate('setup-1', { spriteWidth: 64, spriteHeight: 64 }), + ).rejects.toThrow('角色母版节点当前不能开始生成') + expect(generation.apis.create).not.toHaveBeenCalled() + }) + it('角色母版重生成使用调用方提供的上一版图片作为参考', async () => { const previousImage = 'https://img/knight-previous.png' const { controller, generation } = createController(createRun(completedCharacterNodes())) @@ -859,9 +876,389 @@ describe('WorkflowController', () => { referenceMedia: [previousImage], spriteWidth: 64, spriteHeight: 64, + direction: 'east', + }) + }) + + it.each([ + ['four-way', ['east', 'north', 'south']], + ['eight-way', ['east', 'north', 'south', 'north_east', 'south_east']], + ] as const)('按项目方向为 %s 创建独立的两张候选任务', async (movement, directions) => { + const { controller, generation } = createController(createRun(), movement) + + await controller.generateCharacterTemplate('setup-1', { + spriteWidth: 64, + spriteHeight: 64, + }) + + expect(generation.apis.create).toHaveBeenCalledTimes(directions.length) + for (const [index, direction] of directions.entries()) { + generation.emit({ + taskId: `task-${index + 1}`, + type: 'character_template', + status: 'completed', + result: { + type: 'character_template', + direction, + images: [{ url: `${direction}-1.png` }, { url: `${direction}-2.png` }], + }, + error: null, + }) + } + await flushAsyncWork() + + const template = controller.getWorkflow().nodes[1] + expect(template).toMatchObject({ + phase: 'selecting', + status: 'active', + generations: directions.map((_, index) => ({ + taskId: `task-${index + 1}`, + role: 'character_template', + })), + }) + }) + + it('四向首帧必须逐方向确认,不能用东向选择冒充其它方向', async () => { + const run = createRun([ + setupNode({ status: 'passed', phase: 'completed' }), + templateNode({ + status: 'passed', + phase: 'completed', + selectedImageUrl: 'https://img/east.png', + selectedImages: { + east: 'https://img/east.png', + north: 'https://img/north.png', + south: 'https://img/south.png', + }, + }), + ...actionNodes(), + ]) + const { controller, generation } = createController(run, 'four-way') + + await controller.generateFirstFrame('action-walk', { + spriteWidth: 64, + spriteHeight: 64, + }) + for (const [index, direction] of ['east', 'north', 'south'].entries()) { + generation.emit({ + taskId: `task-${index + 1}`, + type: 'first_frame', + status: 'completed', + result: { + type: 'first_frame', + direction: direction as 'east' | 'north' | 'south', + images: [{ url: `${direction}-1.png` }, { url: `${direction}-2.png` }], + }, + error: null, + }) + } + await flushAsyncWork() + + await controller.confirmFirstFrame('action-walk', 'east-1', 'east') + await controller.confirmFirstFrame('action-walk', 'north-1', 'north') + expect(controller.getWorkflow().nodes.find((node) => node.id === 'action-walk')).toMatchObject({ + status: 'active', + phase: 'selecting', + selectedFirstFrameUrls: { east: 'east-1', north: 'north-1' }, + }) + + await controller.confirmFirstFrame('action-walk', 'south-1', 'south') + expect(controller.getWorkflow().nodes.find((node) => node.id === 'action-walk')).toMatchObject({ + status: 'passed', + phase: 'completed', + selectedFirstFrameUrls: { + east: 'east-1', + north: 'north-1', + south: 'south-1', + }, + }) + }) + + it('四向旧角色只有东向兼容字段时拒绝创建任何首帧任务', async () => { + const run = createRun([...completedCharacterNodes(), ...actionNodes()]) + const { controller, generation } = createController(run, 'four-way') + + await expect( + controller.generateFirstFrame('action-walk', { spriteWidth: 64, spriteHeight: 64 }), + ).rejects.toThrow('角色母版尚未确认方向 north') + + expect(generation.apis.create).not.toHaveBeenCalled() + }) + + it('四向旧首帧只有东向兼容字段时拒绝创建任何完整动画任务', async () => { + const run = createRun([ + ...completedCharacterNodes(), + firstFrameNode({ + status: 'passed', + phase: 'completed', + selectedFirstFrameUrl: 'https://img/east.png', + }), + generationMethodNode({ status: 'passed', phase: 'completed', method: 'video-cropping' }), + fullFrameNode({ status: 'active' }), + reviewNode(), + ]) + const { controller, generation } = createController(run, 'four-way') + + await expect( + controller.generateCompleteAnimation('action-walk:action-full-frame', { + characterId: 'character-1', + referenceMedia: [], + }), + ).rejects.toThrow('动作首帧尚未确认方向 north') + + expect(generation.apis.create).not.toHaveBeenCalled() + }) + + it('拒绝确认镜像方向,并在服务端返回错误方向时终止节点', async () => { + const { controller, generation } = createController(createRun(), 'four-way') + + await expect( + controller.confirmCharacterTemplate('template-1', 'west.png', 'character-1', 'west'), + ).rejects.toThrow('方向 west 是镜像方向,不能单独生成或确认') + + await controller.generateCharacterTemplate('setup-1', { + spriteWidth: 64, + spriteHeight: 64, + }) + generation.emit({ + taskId: 'task-1', + type: 'character_template', + status: 'completed', + result: { + type: 'character_template', + direction: 'north', + images: [{ url: 'north-1.png' }, { url: 'north-2.png' }], + }, + error: null, + }) + await flushAsyncWork() + + expect(controller.getWorkflow().nodes[1]).toMatchObject({ + status: 'failed', + error: '生成结果方向与 WorkflowRun 任务方向不一致', + }) + }) + + it('四向节点等待全部方向,并传播其它方向的失败或错向结果', async () => { + const references = (['east', 'north', 'south'] as const).map((direction) => ({ + taskId: `task-${direction}`, + role: 'character_template' as const, + direction, + })) + const run = createRun([ + setupNode({ status: 'passed', phase: 'completed' }), + templateNode({ status: 'active', phase: 'generating', generations: references }), + ]) + const current = { + id: 'task-east', + projectId: '1', + type: 'character_template' as const, + status: 'completed' as const, + result: { + type: 'character_template' as const, + direction: 'east' as const, + images: [{ url: 'east-1.png' }, { url: 'east-2.png' }], + }, + error: null, + } + const failed = createController(run, 'four-way') + failed.generation.snapshots.set('task-north', { + id: 'task-north', + projectId: '1', + type: 'character_template', + status: 'failed', + result: null, + error: 'north provider failed', + }) + failed.generation.snapshots.set('task-south', { + id: 'task-south', + projectId: '1', + type: 'character_template', + status: 'pending', + result: null, + error: null, + }) + + await failed.controller.applyGenerationResult({ + nodeId: 'template-1', + taskId: 'task-east', + generation: current, + }) + expect(failed.controller.getWorkflow().nodes[1]).toMatchObject({ + status: 'failed', + error: 'north provider failed', + }) + + const mismatched = createController(run, 'four-way') + for (const [taskId, direction] of [ + ['task-north', 'south'], + ['task-south', 'south'], + ] as const) { + mismatched.generation.snapshots.set(taskId, { + id: taskId, + projectId: '1', + type: 'character_template', + status: 'completed', + result: { + type: 'character_template', + direction, + images: [{ url: `${direction}-1.png` }, { url: `${direction}-2.png` }], + }, + error: null, + }) + } + await mismatched.controller.applyGenerationResult({ + nodeId: 'template-1', + taskId: 'task-east', + generation: current, + }) + expect(mismatched.controller.getWorkflow().nodes[1]).toMatchObject({ + status: 'failed', + error: '生成结果方向与 WorkflowRun 任务方向不一致', + }) + + const incomplete = createController( + createRun([ + setupNode({ status: 'passed', phase: 'completed' }), + templateNode({ + status: 'active', + phase: 'generating', + generations: [references[0]!], + }), + ]), + 'four-way', + ) + await incomplete.controller.applyGenerationResult({ + nodeId: 'template-1', + taskId: 'task-east', + generation: current, + }) + expect(incomplete.controller.getWorkflow().nodes[1]).toMatchObject({ + status: 'active', + phase: 'generating', + }) + }) + + it('只重试失败方向并保留其它方向的任务引用', async () => { + const { controller, generation } = createController(createRun(), 'four-way') + + await controller.generateCharacterTemplate('setup-1', { + spriteWidth: 64, + spriteHeight: 64, + }) + for (const [index, direction] of ['east', 'north', 'south'].entries()) { + generation.emit({ + taskId: `task-${index + 1}`, + type: 'character_template', + status: direction === 'north' ? 'failed' : 'completed', + result: + direction === 'north' + ? null + : { + type: 'character_template', + direction: direction as 'east' | 'south', + images: [{ url: `${direction}-1.png` }, { url: `${direction}-2.png` }], + }, + error: direction === 'north' ? 'north provider failed' : null, + }) + } + await flushAsyncWork() + + await controller.retryGenerationDirection('template-1', 'north', { + spriteWidth: 64, + spriteHeight: 64, + }) + + expect(generation.apis.create).toHaveBeenCalledTimes(4) + expect(generation.apis.create).toHaveBeenLastCalledWith( + expect.objectContaining({ type: 'character_template', direction: 'north' }), + ) + expect(controller.getWorkflow().nodes[1]).toMatchObject({ + status: 'active', + phase: 'generating', + generations: [ + { taskId: 'task-1', role: 'character_template' }, + { taskId: 'task-3', role: 'character_template', direction: 'south' }, + { taskId: 'task-4', role: 'character_template', direction: 'north' }, + ], }) }) + it('刷新后恢复其它方向订阅失败时仍保留新建的重试任务引用', async () => { + const run = createRun([ + setupNode({ status: 'passed', phase: 'completed' }), + templateNode({ + status: 'failed', + phase: 'generating', + error: 'north provider failed', + generations: [ + { taskId: 'task-east', role: 'character_template' }, + { taskId: 'task-north', role: 'character_template', direction: 'north' }, + { taskId: 'task-south', role: 'character_template', direction: 'south' }, + ], + }), + ]) + const { controller, generation } = createController(run, 'four-way') + generation.snapshots.set('task-east', { + id: 'task-east', + projectId: '1', + type: 'character_template', + status: 'completed', + result: { + type: 'character_template', + direction: 'east', + images: [{ url: 'east-1.png' }, { url: 'east-2.png' }], + }, + error: null, + }) + generation.snapshots.set('task-north', { + id: 'task-north', + projectId: '1', + type: 'character_template', + status: 'failed', + result: null, + error: 'north provider failed', + }) + generation.snapshots.set('task-south', { + id: 'task-south', + projectId: '1', + type: 'character_template', + status: 'running', + result: null, + error: null, + }) + const readGeneration = vi.mocked(generation.apis.get) + const readSnapshot = readGeneration.getMockImplementation()! + let failSouthRestore = false + readGeneration.mockImplementation(async (projectId, taskId, expectation) => { + if (failSouthRestore && taskId === 'task-south') { + throw new Error('south subscription restore failed') + } + return readSnapshot(projectId, taskId, expectation) + }) + + await controller.resume() + expect(generation.apis.subscribe).not.toHaveBeenCalled() + failSouthRestore = true + await expect( + controller.retryGenerationDirection('template-1', 'north', { + spriteWidth: 64, + spriteHeight: 64, + }), + ).rejects.toThrow('south subscription restore failed') + + expect(generation.apis.subscribe).toHaveBeenCalledWith( + '1', + 'task-south', + expect.objectContaining({ type: 'character_template', direction: 'south' }), + expect.any(Function), + expect.any(Function), + ) + expect(controller.getWorkflow().nodes[1]?.generations).toEqual( + expect.arrayContaining([expect.objectContaining({ taskId: 'task-1', direction: 'north' })]), + ) + }) + it('角色母版微调由 Controller 读取上一版图片并组合临时描述', async () => { const previousImage = 'https://img/knight.png' const { controller, generation } = createController(createRun(completedCharacterNodes())) @@ -880,6 +1277,7 @@ describe('WorkflowController', () => { referenceMedia: [previousImage], spriteWidth: 64, spriteHeight: 64, + direction: 'east', }) expect(controller.getWorkflow().nodes).toEqual( expect.arrayContaining([ @@ -907,6 +1305,7 @@ describe('WorkflowController', () => { referenceMedia: [], spriteWidth: 64, spriteHeight: 64, + direction: 'east', }) }) @@ -1065,6 +1464,36 @@ describe('WorkflowController', () => { ) }) + it('角色母版任务已被并发请求挂载时复用相同引用', async () => { + const { controller, workflow, generation } = createController( + createRun(completedCharacterNodes()), + ) + const update = vi.mocked(workflow.apis.update) + const save = update.getMockImplementation()! + update.mockImplementationOnce(save).mockImplementationOnce(async (run) => { + const saved = await save(run) + return { + ...saved, + nodes: saved.nodes.map((node) => + node.id === 'template-1' + ? { ...node, generations: [{ taskId: 'task-1', role: 'character_template' as const }] } + : node, + ), + } + }) + + await controller.regenerateCharacterTemplate('template-1', { + spriteWidth: 64, + spriteHeight: 64, + mode: 'regenerate', + }) + + expect(generation.apis.create).toHaveBeenCalledOnce() + expect(controller.getWorkflow().nodes[1]).toMatchObject({ + generations: [{ taskId: 'task-1', role: 'character_template' }], + }) + }) + it('角色设定已落库但生成请求失败后可以重试', async () => { const { controller, generation } = createController() vi.mocked(generation.apis.create).mockRejectedValueOnce(new Error('生成服务暂时不可用')) @@ -1104,11 +1533,7 @@ describe('WorkflowController', () => { status: 'completed', result: { type: 'character_template', - images: [ - { url: 'https://img/knight-1.png' }, - { url: 'https://img/knight-2.png' }, - { url: 'https://img/knight-3.png' }, - ], + images: [{ url: 'https://img/knight-1.png' }, { url: 'https://img/knight-2.png' }], }, error: null, } @@ -1158,11 +1583,7 @@ describe('WorkflowController', () => { status: 'completed', result: { type: 'character_template', - images: [ - { url: 'https://img/knight-1.png' }, - { url: 'https://img/knight-2.png' }, - { url: 'https://img/knight-3.png' }, - ], + images: [{ url: 'https://img/knight-1.png' }, { url: 'https://img/knight-2.png' }], }, error: null, }) @@ -1183,11 +1604,7 @@ describe('WorkflowController', () => { status: 'completed', result: { type: 'first_frame', - images: [ - { url: 'https://img/walk-1.png' }, - { url: 'https://img/walk-2.png' }, - { url: 'https://img/walk-3.png' }, - ], + images: [{ url: 'https://img/walk-1.png' }, { url: 'https://img/walk-2.png' }], }, error: null, } @@ -1744,6 +2161,50 @@ describe('WorkflowController', () => { }) }) + it('完整动画节点拒绝图片任务结果', async () => { + const run = createRun([ + ...completedCharacterNodes(), + firstFrameNode({ + status: 'passed', + phase: 'completed', + selectedFirstFrameUrl: 'https://img/first.png', + }), + generationMethodNode({ + status: 'passed', + phase: 'completed', + method: 'video-cropping', + }), + fullFrameNode({ + status: 'active', + phase: 'generating', + generations: [{ taskId: 'task-animation', role: 'complete_animation' }], + }), + reviewNode(), + ]) + const { controller } = createController(run) + + await controller.applyGenerationResult({ + nodeId: 'action-walk:action-full-frame', + taskId: 'task-animation', + generation: { + id: 'task-animation', + projectId: '1', + type: 'first_frame', + status: 'completed', + result: { + type: 'first_frame', + images: [{ url: 'wrong-1.png' }, { url: 'wrong-2.png' }], + }, + error: null, + }, + }) + + expect(controller.getWorkflow().nodes[4]).toMatchObject({ + status: 'failed', + error: '完整动画结果格式无效', + }) + }) + it('一个并行 Action 失败不会阻止另一个 Action 接收生成结果', async () => { const run = createRun([ ...completedCharacterNodes(), @@ -1782,7 +2243,7 @@ describe('WorkflowController', () => { status: 'completed', result: { type: 'first_frame', - images: [{ url: 'jump-1.png' }, { url: 'jump-2.png' }, { url: 'jump-3.png' }], + images: [{ url: 'jump-1.png' }, { url: 'jump-2.png' }], }, error: null, }, @@ -1810,11 +2271,7 @@ describe('WorkflowController', () => { status: 'completed', result: { type: 'first_frame', - images: [ - { url: 'https://img/first.png' }, - { url: 'https://img/first-2.png' }, - { url: 'https://img/first-3.png' }, - ], + images: [{ url: 'https://img/first.png' }, { url: 'https://img/first-2.png' }], }, error: null, }) @@ -1841,6 +2298,7 @@ describe('WorkflowController', () => { spriteWidth: 64, spriteHeight: 96, referenceMedia: ['https://img/knight.png'], + direction: 'east', }) expect(generation.apis.create).toHaveBeenNthCalledWith( 2, @@ -1899,6 +2357,7 @@ describe('WorkflowController', () => { referenceMedia: [previousImage], spriteWidth: 64, spriteHeight: 96, + direction: 'east', }) }) @@ -1933,6 +2392,7 @@ describe('WorkflowController', () => { referenceMedia: [previousImage], spriteWidth: 64, spriteHeight: 96, + direction: 'east', }) }) @@ -1965,6 +2425,7 @@ describe('WorkflowController', () => { referenceMedia: ['https://img/knight.png'], spriteWidth: 64, spriteHeight: 96, + direction: 'east', }) }) @@ -2092,11 +2553,7 @@ describe('WorkflowController', () => { status: 'completed', result: { type: 'first_frame', - images: [ - { url: 'https://img/first.png' }, - { url: 'https://img/first-2.png' }, - { url: 'https://img/first-3.png' }, - ], + images: [{ url: 'https://img/first.png' }, { url: 'https://img/first-2.png' }], }, error: null, }) diff --git a/frontend/src/features/workflow-controller/controller.ts b/frontend/src/features/workflow-controller/controller.ts index 7a6d843e..09318d77 100644 --- a/frontend/src/features/workflow-controller/controller.ts +++ b/frontend/src/features/workflow-controller/controller.ts @@ -3,6 +3,7 @@ import type { ActionFullFrameWorkflowNode, ActionGenerationMethod, ActionGenerationMethodWorkflowNode, + ActionDirection, CharacterTemplateGenerationInput, CharacterSetupWorkflowNode, CharacterTemplateWorkflowNode, @@ -23,7 +24,9 @@ import type { WorkflowNode, WorkflowRun, WorkflowRunApis, + DirectionalMovement, } from '@/entities' +import { getDirectionProfile } from '@/entities' import { IMAGE_CANDIDATE_COUNT, WorkflowRunConflictError } from '@/entities' const COMPLETE_ANIMATION_FRAME_COUNT = 32 @@ -72,6 +75,13 @@ export interface RegenerateImageOptions { adjustmentPrompt?: string } +export interface RetryGenerationDirectionOptions { + spriteWidth: number + spriteHeight: number + /** 完整动画重试时沿用调用入口持有的额外参考媒体;图片任务忽略此字段。 */ + referenceMedia?: readonly MediaReference[] +} + export interface ApplyGenerationResultInput { nodeId: WorkflowNode['id'] taskId: Generation['id'] @@ -87,6 +97,8 @@ export interface CreateWorkflowControllerOptions { now?: () => string /** SSE 回调无法 await,异步保存错误通过此处交给装配层展示或记录。 */ onAsyncError: (error: Error) => void + /** 项目方向模式;缺省按旧单向 WorkflowRun 兼容。 */ + directionalMovement?: DirectionalMovement } /** @@ -126,6 +138,7 @@ export interface WorkflowController { nodeId: CharacterTemplateWorkflowNode['id'], selectedImageUrl: string, characterId: string, + direction?: ActionDirection, ): Promise generateFirstFrame( nodeId: ActionFirstFrameWorkflowNode['id'], @@ -138,6 +151,7 @@ export interface WorkflowController { confirmFirstFrame( nodeId: ActionFirstFrameWorkflowNode['id'], selectedFirstFrameUrl: string, + direction?: ActionDirection, ): Promise selectActionGenerationMethod( nodeId: ActionGenerationMethodWorkflowNode['id'], @@ -161,6 +175,14 @@ export interface WorkflowController { nodeId: WorkflowNode['id'], role: WorkflowGenerationRole, ): Promise + /** 读取同一节点下各真实源方向的任务结果。 */ + getGenerations(nodeId: WorkflowNode['id'], role: WorkflowGenerationRole): Promise + /** 只替换一个失败或待选源方向的任务,保留同节点其它方向的引用与结果。 */ + retryGenerationDirection( + nodeId: WorkflowNode['id'], + direction: ActionDirection, + options: RetryGenerationDirectionOptions, + ): Promise dispose(): void } @@ -173,6 +195,7 @@ interface ActiveSubscription { interface PendingGenerationAttachment { nodeId: WorkflowNode['id'] role: WorkflowGenerationRole + direction: ActionDirection expectedEpoch: number regeneration: boolean generation: Generation @@ -185,6 +208,7 @@ export function createWorkflowController({ createId = createBrowserSafeId, now = () => new Date().toISOString(), onAsyncError, + directionalMovement = 'single', }: CreateWorkflowControllerOptions): WorkflowController { let current = workflow ? structuredClone(workflow) : null let interrupted = false @@ -197,6 +221,15 @@ export function createWorkflowController({ const regenerationKeys = new Set() const settlements = new Map>() const listeners = new Set<(workflow: WorkflowRun) => void>() + const sourceDirections = getDirectionProfile(directionalMovement).sourceDirections + + function selectedDirectionUrl( + values: Partial> | undefined, + legacyEastUrl: string | null | undefined, + direction: ActionDirection, + ) { + return values?.[direction] ?? (direction === 'east' ? legacyEastUrl : undefined) + } function requireWorkflow(): WorkflowRun { if (!current) throw new Error('WorkflowController 尚未绑定 WorkflowRun') @@ -417,33 +450,42 @@ export function createWorkflowController({ ) }) const templateNode = findSingleDependentNode(advanced, nodeId, 'character-template') - return submitGeneration(templateNode.id, 'character_template', (run, node) => { - if (node.type !== 'character-template') throw new Error('目标节点不是角色母版') - if (node.phase !== 'ready') throw new Error('角色母版节点当前不能开始生成') - const setupNode = findSingleDependencyNode(run, node, 'character-setup') - const input: CharacterTemplateGenerationInput = { - type: 'character_template', - projectId: run.projectId, - prompt: - options.prompt === undefined - ? setupNode.input.prompt - : nonEmpty(options.prompt, 'prompt'), - referenceMedia: sourceImage ? [sourceImage] : setupNode.input.referenceMedia, - spriteWidth: options.spriteWidth, - spriteHeight: options.spriteHeight, - } - return input - }) + return submitDirectionalGenerations( + templateNode.id, + 'character_template', + (run, node, direction) => { + if (node.type !== 'character-template') throw new Error('目标节点不是角色母版') + if (node.phase !== 'ready' && node.phase !== 'generating') { + throw new Error('角色母版节点当前不能开始生成') + } + const setupNode = findSingleDependencyNode(run, node, 'character-setup') + const input: CharacterTemplateGenerationInput = { + type: 'character_template', + projectId: run.projectId, + prompt: + options.prompt === undefined + ? setupNode.input.prompt + : nonEmpty(options.prompt, 'prompt'), + referenceMedia: sourceImage ? [sourceImage] : setupNode.input.referenceMedia, + spriteWidth: options.spriteWidth, + spriteHeight: options.spriteHeight, + direction, + } + return input + }, + ) } function confirmCharacterTemplate( nodeId: CharacterTemplateWorkflowNode['id'], selectedImageUrl: string, characterId: string, + direction: ActionDirection = 'east', ) { ensureRunning() const imageUrl = nonEmpty(selectedImageUrl, 'selectedImageUrl') const normalizedCharacterId = nonEmpty(characterId, 'characterId') + assertSourceDirection(direction, sourceDirections) return persist((run) => { const templateNode = findNode(run, nodeId) if (templateNode.type !== 'character-template') throw new Error('目标节点不是角色母版') @@ -464,11 +506,22 @@ export function createWorkflowController({ } } if (node.id === templateNode.id) { + const selectedImages = { + ...(templateNode.selectedImages ?? {}), + [direction]: imageUrl, + } + const complete = sourceDirections.every((sourceDirection) => { + return Boolean(selectedImages[sourceDirection]) + }) return { ...templateNode, - selectedImageUrl: imageUrl, - phase: 'completed', - status: 'passed', + selectedImageUrl: + direction === 'east' + ? imageUrl + : (templateNode.selectedImageUrl ?? selectedImages.east ?? null), + selectedImages, + phase: complete ? 'completed' : 'selecting', + status: complete ? 'passed' : 'active', } } return node @@ -538,6 +591,11 @@ export function createWorkflowController({ return { ...templateNode, selectedImageUrl: imageUrl, + // 上传的是一张通用母版,不代表它只属于 east;它会作为每个真实 + // 源方向的生成约束,动作差异仍由 direction prompt 负责生成。 + selectedImages: Object.fromEntries( + sourceDirections.map((sourceDirection) => [sourceDirection, imageUrl]), + ), status: 'passed', phase: 'completed', } @@ -553,27 +611,51 @@ export function createWorkflowController({ options: GenerateFirstFrameOptions, ) { const sourceImage = generatedImageReference(options.sourceImageUrl) - return submitGeneration(nodeId, 'first_frame', (run, node) => { - if (node.type !== 'action-first-frame') throw new Error('目标节点不是动作首帧') - if (node.phase !== 'configuring') throw new Error('动作首帧节点当前不能生成') - const templateNode = findSingleDependencyNode(run, node, 'character-template') - if (!templateNode.selectedImageUrl) throw new Error('角色母版尚未确认') - // 该 URL 来自已确认的角色母版,是动作首帧图片任务唯一的参考图。 - const characterTemplateReference = templateNode.selectedImageUrl as MediaReference - const input: FirstFrameGenerationInput = { - type: 'first_frame', - projectId: run.projectId, - actionType: node.input.type, - prompt: - options.prompt === undefined - ? node.input.prompt?.trim() || node.input.name - : nonEmpty(options.prompt, 'prompt'), - spriteWidth: options.spriteWidth, - spriteHeight: options.spriteHeight, - referenceMedia: [sourceImage ?? characterTemplateReference], + const before = requireWorkflow() + const targetNode = findNode(before, nodeId) + if (targetNode.type !== 'action-first-frame') throw new Error('目标节点不是动作首帧') + const targetTemplate = findSingleDependencyNode(before, targetNode, 'character-template') + for (const direction of sourceDirections) { + if ( + !selectedDirectionUrl( + targetTemplate.selectedImages, + targetTemplate.selectedImageUrl, + direction, + ) + ) { + throw new Error(`角色母版尚未确认方向 ${direction}`) } - return input - }) + } + return submitDirectionalGenerations( + nodeId, + 'first_frame', + (run, node, direction) => { + if (node.type !== 'action-first-frame') throw new Error('目标节点不是动作首帧') + if (node.phase !== 'configuring') throw new Error('动作首帧节点当前不能生成') + const templateNode = findSingleDependencyNode(run, node, 'character-template') + const characterTemplateReference = selectedDirectionUrl( + templateNode.selectedImages, + templateNode.selectedImageUrl, + direction, + ) + if (!characterTemplateReference) throw new Error(`角色母版尚未确认方向 ${direction}`) + const input: FirstFrameGenerationInput = { + type: 'first_frame', + projectId: run.projectId, + actionType: node.input.type, + prompt: + options.prompt === undefined + ? node.input.prompt?.trim() || node.input.name + : nonEmpty(options.prompt, 'prompt'), + spriteWidth: options.spriteWidth, + spriteHeight: options.spriteHeight, + referenceMedia: [sourceImage ?? (characterTemplateReference as MediaReference)], + direction, + } + return input + }, + sourceDirections, + ) } async function regenerateCharacterTemplate( @@ -595,12 +677,15 @@ export function createWorkflowController({ const setupNode = findSingleDependencyNode(before, templateNode, 'character-setup') const prompt = adjustedPrompt(setupNode.input.prompt, options) const sourceImageUrl = options.mode === 'refine' ? templateNode.selectedImageUrl : undefined - const key = `${nodeId}:character_template` - const pending = unattachedGenerations.get(key)?.regeneration - ? unattachedGenerations.get(key) - : undefined + const keys = sourceDirections.map((direction) => + generationKey(nodeId, 'character_template', direction), + ) + const pending = keys.flatMap((key) => { + const attachment = unattachedGenerations.get(key) + return attachment?.regeneration ? [attachment] : [] + }) await restartFromNode(nodeId) - return runRegenerationAttempt(before, nodeId, key, pending, () => { + return runRegenerationAttempt(before, nodeId, keys, pending, () => { return generateCharacterTemplate(setupNode.id, { spriteWidth: options.spriteWidth, spriteHeight: options.spriteHeight, @@ -630,12 +715,15 @@ export function createWorkflowController({ const prompt = adjustedPrompt(basePrompt, options) const sourceImageUrl = options.mode === 'refine' ? firstFrameNode.selectedFirstFrameUrl : undefined - const key = `${nodeId}:first_frame` - const pending = unattachedGenerations.get(key)?.regeneration - ? unattachedGenerations.get(key) - : undefined + const keys = sourceDirections.map((direction) => + generationKey(nodeId, 'first_frame', direction), + ) + const pending = keys.flatMap((key) => { + const attachment = unattachedGenerations.get(key) + return attachment?.regeneration ? [attachment] : [] + }) await restartFromNode(nodeId) - return runRegenerationAttempt(before, nodeId, key, pending, () => { + return runRegenerationAttempt(before, nodeId, keys, pending, () => { return generateFirstFrame(nodeId, { spriteWidth: options.spriteWidth, spriteHeight: options.spriteHeight, @@ -648,17 +736,22 @@ export function createWorkflowController({ async function runRegenerationAttempt( before: WorkflowRun, nodeId: WorkflowNode['id'], - key: string, - pending: PendingGenerationAttachment | undefined, + keys: readonly string[], + pending: readonly PendingGenerationAttachment[], generate: () => Promise, ): Promise { try { - if (pending) { - const retryAttachment = { ...pending, expectedEpoch: nodeEpoch(nodeId) } + for (const attachment of pending) { + const retryAttachment = { ...attachment, expectedEpoch: nodeEpoch(nodeId) } + const key = generationKey( + retryAttachment.nodeId, + retryAttachment.role, + retryAttachment.direction, + ) unattachedGenerations.set(key, retryAttachment) - return await attachGeneration(retryAttachment) + await attachGeneration(retryAttachment) } - regenerationKeys.add(key) + keys.forEach((key) => regenerationKeys.add(key)) return await generate() } catch (cause) { try { @@ -668,7 +761,7 @@ export function createWorkflowController({ } throw cause } finally { - regenerationKeys.delete(key) + keys.forEach((key) => regenerationKeys.delete(key)) } } @@ -701,27 +794,183 @@ export function createWorkflowController({ function confirmFirstFrame( nodeId: ActionFirstFrameWorkflowNode['id'], selectedFirstFrameUrl: string, + direction: ActionDirection = 'east', ) { ensureRunning() const imageUrl = nonEmpty(selectedFirstFrameUrl, 'selectedFirstFrameUrl') + assertSourceDirection(direction, sourceDirections) return persist((run) => updateNode(run, nodeId, (node) => { if (node.type !== 'action-first-frame') throw new Error('目标节点不是动作首帧') if (node.status !== 'active' || node.phase !== 'selecting') { throw new Error('动作首帧节点当前不能确认首帧') } + const selectedFirstFrameUrls = { + ...(node.selectedFirstFrameUrls ?? {}), + [direction]: imageUrl, + } + const complete = sourceDirections.every((sourceDirection) => + Boolean(selectedFirstFrameUrls[sourceDirection]), + ) return unlockReadyNodes( replaceNode(run, { ...node, - selectedFirstFrameUrl: imageUrl, - status: 'passed', - phase: 'completed', + selectedFirstFrameUrl: + direction === 'east' + ? imageUrl + : (node.selectedFirstFrameUrl ?? node.selectedFirstFrameUrls?.east ?? null), + selectedFirstFrameUrls, + status: complete ? 'passed' : 'active', + phase: complete ? 'completed' : 'selecting', }), ) }), ) } + async function retryGenerationDirection( + nodeId: WorkflowNode['id'], + direction: ActionDirection, + options: RetryGenerationDirectionOptions, + ): Promise { + ensureRunning() + assertSourceDirection(direction, sourceDirections) + const before = requireWorkflow() + const originalNode = structuredClone(findNode(before, nodeId)) + const role = generationRoleForNode(originalNode) + if (!role) throw new Error('目标节点不是生成节点') + if (originalNode.status !== 'failed' && originalNode.phase !== 'selecting') { + throw new Error('当前方向不能重新生成') + } + const reference = originalNode.generations.find( + (item) => item.role === role && generationReferenceDirection(item) === direction, + ) + if (!reference) throw new Error(`方向 ${direction} 没有可替换的生成任务`) + if (originalNode.type !== 'action-full-frame') { + ensurePositiveInteger(options.spriteWidth, 'spriteWidth') + ensurePositiveInteger(options.spriteHeight, 'spriteHeight') + } + + stopSubscription(subscriptionKey(nodeId, reference.taskId)) + await persist((run) => { + const node = findNode(run, nodeId) + const generations = node.generations.filter((item) => item.taskId !== reference.taskId) + if (node.type === 'character-template') { + const selectedImages = { ...(node.selectedImages ?? {}) } + delete selectedImages[direction] + return replaceNode(run, { + ...node, + status: 'active', + phase: 'generating', + generations, + selectedImageUrl: direction === 'east' ? null : node.selectedImageUrl, + selectedImages, + error: null, + }) + } + if (node.type === 'action-first-frame') { + const selectedFirstFrameUrls = { ...(node.selectedFirstFrameUrls ?? {}) } + delete selectedFirstFrameUrls[direction] + return replaceNode(run, { + ...node, + status: 'active', + phase: 'generating', + generations, + selectedFirstFrameUrl: direction === 'east' ? null : node.selectedFirstFrameUrl, + selectedFirstFrameUrls, + error: null, + }) + } + if (node.type === 'action-full-frame') { + return replaceNode(run, { + ...node, + status: 'active', + phase: 'generating', + generations, + error: null, + }) + } + throw new Error('目标节点不是生成节点') + }) + + try { + await submitGeneration( + nodeId, + role, + (run, node, retryDirection) => { + if (node.type === 'character-template') { + const setupNode = findSingleDependencyNode(run, node, 'character-setup') + const input: CharacterTemplateGenerationInput = { + type: 'character_template', + projectId: run.projectId, + prompt: setupNode.input.prompt, + referenceMedia: setupNode.input.referenceMedia, + spriteWidth: options.spriteWidth, + spriteHeight: options.spriteHeight, + direction: retryDirection, + } + return input + } + if (node.type === 'action-first-frame') { + const templateNode = findSingleDependencyNode(run, node, 'character-template') + const templateUrl = selectedDirectionUrl( + templateNode.selectedImages, + templateNode.selectedImageUrl, + retryDirection, + ) + if (!templateUrl) throw new Error(`角色母版尚未确认方向 ${retryDirection}`) + const input: FirstFrameGenerationInput = { + type: 'first_frame', + projectId: run.projectId, + actionType: node.input.type, + prompt: node.input.prompt?.trim() || node.input.name, + spriteWidth: options.spriteWidth, + spriteHeight: options.spriteHeight, + referenceMedia: [templateUrl as MediaReference], + direction: retryDirection, + } + return input + } + if (node.type === 'action-full-frame') { + const methodNode = findSingleDependencyNode(run, node, 'action-generation-method') + if (!methodNode.method) throw new Error('尚未选择动作生成方式') + const firstFrameNode = findSingleDependencyNode(run, methodNode, 'action-first-frame') + const templateNode = findSingleDependencyNode(run, firstFrameNode, 'character-template') + const setupNode = findSingleDependencyNode(run, templateNode, 'character-setup') + const characterId = nonEmpty(setupNode.input.characterId ?? '', 'characterId') + const firstFrameUrl = selectedDirectionUrl( + firstFrameNode.selectedFirstFrameUrls, + firstFrameNode.selectedFirstFrameUrl, + retryDirection, + ) + if (!firstFrameUrl) throw new Error(`动作首帧尚未确认方向 ${retryDirection}`) + const input: CompleteAnimationGenerationInput = { + type: 'complete_animation', + projectId: run.projectId, + characterId, + outfitId: firstFrameNode.input.outfitId, + method: methodNode.method, + actionType: firstFrameNode.input.type, + firstFrameUrl, + prompt: firstFrameNode.input.prompt, + referenceMedia: options.referenceMedia ?? [], + direction: retryDirection, + } + return input + } + throw new Error('目标节点不是生成节点') + }, + direction, + ) + } catch (cause) { + await persist((run) => replaceNode(run, originalNode)) + throw cause + } + // 新任务已经持久化后不能再回滚,否则恢复订阅的瞬时失败会遗失付费任务引用。 + // 刷新后的失败节点不会自动恢复订阅,因此这里单独恢复其它仍在运行的方向。 + return resume() + } + function selectActionGenerationMethod( nodeId: ActionGenerationMethodWorkflowNode['id'], method: ActionGenerationMethod, @@ -750,26 +999,53 @@ export function createWorkflowController({ options: GenerateActionOptions, ) { const characterId = nonEmpty(options.characterId, 'characterId') - return submitGeneration(nodeId, 'complete_animation', (run, node) => { - if (node.type !== 'action-full-frame') throw new Error('目标节点不是完整动画') - if (node.phase !== 'ready') throw new Error('完整动画节点当前不能生成') - const methodNode = findSingleDependencyNode(run, node, 'action-generation-method') - if (!methodNode.method) throw new Error('尚未选择动作生成方式') - const firstFrameNode = findSingleDependencyNode(run, methodNode, 'action-first-frame') - if (!firstFrameNode.selectedFirstFrameUrl) throw new Error('动作首帧尚未确认') - const input: CompleteAnimationGenerationInput = { - type: 'complete_animation', - projectId: run.projectId, - characterId, - outfitId: firstFrameNode.input.outfitId, - method: methodNode.method, - actionType: firstFrameNode.input.type, - firstFrameUrl: firstFrameNode.selectedFirstFrameUrl, - prompt: firstFrameNode.input.prompt, - referenceMedia: options.referenceMedia, + const before = requireWorkflow() + const targetNode = findNode(before, nodeId) + if (targetNode.type !== 'action-full-frame') throw new Error('目标节点不是完整动画') + const targetMethod = findSingleDependencyNode(before, targetNode, 'action-generation-method') + const targetFirstFrame = findSingleDependencyNode(before, targetMethod, 'action-first-frame') + for (const direction of sourceDirections) { + if ( + !selectedDirectionUrl( + targetFirstFrame.selectedFirstFrameUrls, + targetFirstFrame.selectedFirstFrameUrl, + direction, + ) + ) { + throw new Error(`动作首帧尚未确认方向 ${direction}`) } - return input - }) + } + return submitDirectionalGenerations( + nodeId, + 'complete_animation', + (run, node, direction) => { + if (node.type !== 'action-full-frame') throw new Error('目标节点不是完整动画') + if (node.phase !== 'ready') throw new Error('完整动画节点当前不能生成') + const methodNode = findSingleDependencyNode(run, node, 'action-generation-method') + if (!methodNode.method) throw new Error('尚未选择动作生成方式') + const firstFrameNode = findSingleDependencyNode(run, methodNode, 'action-first-frame') + const firstFrameUrl = selectedDirectionUrl( + firstFrameNode.selectedFirstFrameUrls, + firstFrameNode.selectedFirstFrameUrl, + direction, + ) + if (!firstFrameUrl) throw new Error(`动作首帧尚未确认方向 ${direction}`) + const input: CompleteAnimationGenerationInput = { + type: 'complete_animation', + projectId: run.projectId, + characterId, + outfitId: firstFrameNode.input.outfitId, + method: methodNode.method, + actionType: firstFrameNode.input.type, + firstFrameUrl, + prompt: firstFrameNode.input.prompt, + referenceMedia: options.referenceMedia, + direction, + } + return input + }, + sourceDirections, + ) } function approveReview(nodeId: ReviewWorkflowNode['id']) { @@ -815,10 +1091,15 @@ export function createWorkflowController({ function submitGeneration( nodeId: WorkflowNode['id'], role: WorkflowGenerationRole, - createInput: (run: WorkflowRun, node: WorkflowNode) => Parameters[0], + createInput: ( + run: WorkflowRun, + node: WorkflowNode, + direction: ActionDirection, + ) => Parameters[0], + direction: ActionDirection = 'east', ): Promise { ensureRunning() - const key = `${nodeId}:${role}` + const key = generationKey(nodeId, role, direction) const active = submissions.get(key) if (active) return active @@ -828,6 +1109,7 @@ export function createWorkflowController({ role, expectedEpoch, createInput, + direction, ).finally(() => { if (submissions.get(key) === submission) submissions.delete(key) }) @@ -835,17 +1117,43 @@ export function createWorkflowController({ return submission } + async function submitDirectionalGenerations( + nodeId: WorkflowNode['id'], + role: WorkflowGenerationRole, + createInput: ( + run: WorkflowRun, + node: WorkflowNode, + direction: ActionDirection, + ) => Parameters[0], + directions: readonly ActionDirection[] = sourceDirections, + ): Promise { + if (directions.length === 0) throw new Error('项目没有可生成的真实源方向') + // 方向之间彼此独立,可以并行排队;每个任务仍有自己的 taskId、SSE 订阅和落库引用。 + // 镜像方向没有进入 directions,因此不会触发模型调用或积分扣除。 + const snapshots = await Promise.all( + directions.map((direction) => submitGeneration(nodeId, role, createInput, direction)), + ) + return snapshots[snapshots.length - 1] ?? snapshot() + } + async function performGenerationSubmission( nodeId: WorkflowNode['id'], role: WorkflowGenerationRole, expectedEpoch: number, - createInput: (run: WorkflowRun, node: WorkflowNode) => Parameters[0], + createInput: ( + run: WorkflowRun, + node: WorkflowNode, + direction: ActionDirection, + ) => Parameters[0], + direction: ActionDirection, ): Promise { const before = requireWorkflow() const node = findNode(before, nodeId) assertNodeCanRun(before, node) - const key = `${nodeId}:${role}` - const existing = node.generations.find((item) => item.role === role) + const key = generationKey(nodeId, role, direction) + const existing = node.generations.find( + (item) => item.role === role && generationReferenceDirection(item) === direction, + ) if (existing) { await watchGeneration(node.id, existing.taskId) return snapshot() @@ -857,7 +1165,7 @@ export function createWorkflowController({ } if (pendingAttachment) unattachedGenerations.delete(key) - const generation = await generationApis.create(createInput(before, node)) + const generation = await generationApis.create(createInput(before, node, direction)) if (generation.projectId !== before.projectId) { throw new Error('Generation 与 WorkflowRun 不属于同一项目') } @@ -867,6 +1175,7 @@ export function createWorkflowController({ const attachment = { nodeId, role, + direction, expectedEpoch, regeneration: regenerationKeys.has(key), generation, @@ -878,10 +1187,11 @@ export function createWorkflowController({ async function attachGeneration({ nodeId, role, + direction, expectedEpoch, generation, }: PendingGenerationAttachment): Promise { - const key = `${nodeId}:${role}` + const key = generationKey(nodeId, role, direction) if (nodeEpoch(nodeId) !== expectedEpoch) { if (unattachedGenerations.get(key)?.generation.id === generation.id) { unattachedGenerations.delete(key) @@ -891,12 +1201,21 @@ export function createWorkflowController({ const attached = await persist((latest) => { if (nodeEpoch(nodeId) !== expectedEpoch) return latest const latestNode = findNode(latest, nodeId) - if (latestNode.generations.some((item) => item.role === role)) return latest + if ( + latestNode.generations.some( + (item) => item.role === role && generationReferenceDirection(item) === direction, + ) + ) { + return latest + } assertNodeCanRun(latest, latestNode) - return replaceNode(latest, attachGenerationReference(latestNode, generation.id, role)) + return replaceNode( + latest, + attachGenerationReference(latestNode, generation.id, role, direction), + ) }) const attachedReference = findNode(attached, nodeId).generations.find( - (item) => item.role === role, + (item) => item.role === role && generationReferenceDirection(item) === direction, ) const forgetPendingAttachment = () => { if (unattachedGenerations.get(key)?.generation.id === generation.id) { @@ -926,7 +1245,9 @@ export function createWorkflowController({ subscriptions.set(key, { nodeId, taskId, stop: () => undefined }) try { const run = requireWorkflow() - const expectation = generationExpectationForNode(run, findNode(run, nodeId)) + const node = findNode(run, nodeId) + const reference = node.generations.find((item) => item.taskId === taskId) + const expectation = generationExpectationForNode(run, node, reference?.direction) if (!expectation) throw new Error(`${nodeId} 不是生成节点`) const stop = generationApis.subscribe( run.projectId, @@ -994,12 +1315,56 @@ export function createWorkflowController({ return applyGenerationResult({ nodeId, taskId, generation: normalized }) } - function applyGenerationResult({ + async function applyGenerationResult({ nodeId, taskId, generation, }: ApplyGenerationResultInput): Promise { if (interrupted) return Promise.resolve(snapshot()) + const before = requireWorkflow() + const node = findNode(before, nodeId) + const reference = node.generations.find((item) => item.taskId === taskId) + if (!reference) return snapshot() + const expectation = generationExpectationForNode( + before, + node, + generationReferenceDirection(reference), + ) + if (!expectation) return snapshot() + + // 方向是任务契约的一部分。服务端返回了错误方向时不能把它静默挂到当前方向, + // 否则四向/八向资产会在导入 Playtest 后出现“名称对得上、画面却错位”的问题。 + if (generationDirectionOf(generation) !== generationReferenceDirection(reference)) { + return persist((run) => { + const currentNode = findNode(run, nodeId) + return currentNode.status === 'active' + ? failNode(run, currentNode, '生成结果方向与 WorkflowRun 任务方向不一致') + : run + }) + } + + const role = reference.role + const hasAllExpectedReferences = sourceDirections.every((direction) => + node.generations.some( + (item) => item.role === role && generationReferenceDirection(item) === direction, + ), + ) + + // 一个节点现在可能挂着 1/3/5 条任务。结算当前任务时重新读取同节点其余 + // 任务,只有全部完成才允许节点进入 selecting/completed;刷新恢复也走同一规则。 + const allGenerations = await Promise.all( + node.generations.map((item) => + item.taskId === taskId + ? generation + : generationApis.get( + before.projectId, + item.taskId, + generationExpectationForNode(before, node, item.direction)!, + ), + ), + ) + const failed = allGenerations.find((item) => item.status === 'failed') + const allCompleted = allGenerations.every((item) => item.status === 'completed') return persist((run) => { if (generation.id !== taskId || generation.projectId !== run.projectId) return run const node = findNode(run, nodeId) @@ -1016,6 +1381,29 @@ export function createWorkflowController({ error: generation.error?.trim() || '生成任务失败', }) } + if (failed) { + return replaceNode(run, { + ...node, + status: 'failed', + error: failed.error?.trim() || '方向生成任务失败', + }) + } + if (!hasAllExpectedReferences) { + return replaceNode(run, { ...node, phase: 'generating', error: null }) + } + if (!allCompleted) return replaceNode(run, { ...node, phase: 'generating', error: null }) + if ( + allGenerations.some( + (item, index) => + generationDirectionOf(item) !== generationReferenceDirection(node.generations[index]!), + ) + ) { + return failNode(run, node, '生成结果方向与 WorkflowRun 任务方向不一致') + } + const invalidGeneration = allGenerations + .map((item) => generationResultError(node, item)) + .find((message): message is string => message !== null) + if (invalidGeneration) return failNode(run, node, invalidGeneration) return applyCompletedGeneration(run, node, reference, generation) }) } @@ -1026,44 +1414,16 @@ export function createWorkflowController({ reference: WorkflowGenerationRef, generation: Generation, ): WorkflowRun { - if (reference.role === 'character_template') { - if ( - node.type !== 'character-template' || - generation.type !== 'character_template' || - generation.result?.type !== 'character_template' || - generation.result.images.length !== IMAGE_CANDIDATE_COUNT - ) { - return failNode(run, node, '角色候选图结果格式无效') - } + const invalid = generationResultError(node, generation) + if (invalid) return failNode(run, node, invalid) + if (reference.role === 'character_template' && node.type === 'character-template') { return replaceNode(run, { ...node, phase: 'selecting', error: null }) } - - if (reference.role === 'first_frame') { - if ( - node.type !== 'action-first-frame' || - generation.type !== 'first_frame' || - generation.result?.type !== 'first_frame' || - generation.result.images.length !== IMAGE_CANDIDATE_COUNT || - generation.result.images.some((image) => !image.url) - ) { - return failNode(run, node, '动作首帧结果格式无效') - } + if (reference.role === 'first_frame' && node.type === 'action-first-frame') { return replaceNode(run, { ...node, phase: 'selecting', error: null }) } - - if ( - node.type !== 'action-full-frame' || - generation.type !== 'complete_animation' || - generation.result?.type !== 'complete_animation' - ) { - return failNode(run, node, '完整动画结果格式无效') - } - if (generation.result.frames.length !== COMPLETE_ANIMATION_FRAME_COUNT) { - return failNode( - run, - node, - `完整动画应为 ${COMPLETE_ANIMATION_FRAME_COUNT} 帧,实际为 ${generation.result.frames.length} 帧`, - ) + if (reference.role !== 'complete_animation' || node.type !== 'action-full-frame') { + return failNode(run, node, '生成任务角色不匹配') } return unlockReadyNodes( replaceNode(run, { ...node, status: 'passed', phase: 'completed', error: null }), @@ -1080,8 +1440,9 @@ export function createWorkflowController({ if (node.deletedAt || node.status !== 'active' || !isGeneratingPhase(node)) return [] const role = generationRoleForNode(node) if (!role) return [] - const reference = node.generations.find((item) => item.role === role) - return reference ? [{ nodeId: node.id, taskId: reference.taskId }] : [] + return node.generations + .filter((item) => item.role === role) + .map((reference) => ({ nodeId: node.id, taskId: reference.taskId })) }) await Promise.all(tasks.map((task) => watchGeneration(task.nodeId, task.taskId))) return snapshot() @@ -1131,13 +1492,26 @@ export function createWorkflowController({ } async function getGeneration(nodeId: WorkflowNode['id'], role: WorkflowGenerationRole) { + const generations = await getGenerations(nodeId, role) + return ( + generations.find((generation) => generationDirectionOf(generation) === 'east') ?? + generations[0] ?? + null + ) + } + + async function getGenerations(nodeId: WorkflowNode['id'], role: WorkflowGenerationRole) { const run = requireWorkflow() const node = findNode(run, nodeId) - const reference = node.generations.find((item) => item.role === role) - const expectation = generationExpectationForNode(run, node) - return reference && expectation - ? generationApis.get(run.projectId, reference.taskId, expectation) - : null + const references = node.generations.filter((item) => item.role === role) + return Promise.all( + references.map((reference) => { + const expectation = generationExpectationForNode(run, node, reference.direction) + return expectation + ? generationApis.get(run.projectId, reference.taskId, expectation) + : Promise.reject(new Error(`${nodeId} 不是生成节点`)) + }), + ) } function stopSubscription(key: string) { @@ -1187,6 +1561,8 @@ export function createWorkflowController({ restartFromNode: asCommand(restartFromNode), applyGenerationResult: asCommand(applyGenerationResult), getGeneration, + getGenerations, + retryGenerationDirection: asCommand(retryGenerationDirection), dispose, } } @@ -1330,18 +1706,55 @@ function generationRoleForNode(node: WorkflowNode): WorkflowGenerationRole | nul return null } +function generationResultError(node: WorkflowNode, generation: Generation): string | null { + if (node.type === 'character-template') { + return generation.type === 'character_template' && + generation.result?.type === 'character_template' && + generation.result.images.length === IMAGE_CANDIDATE_COUNT + ? null + : '角色候选图结果格式无效' + } + + if (node.type === 'action-first-frame') { + return generation.type === 'first_frame' && + generation.result?.type === 'first_frame' && + generation.result.images.length === IMAGE_CANDIDATE_COUNT && + generation.result.images.every((image) => Boolean(image.url)) + ? null + : '动作首帧结果格式无效' + } + + if (node.type === 'action-full-frame') { + if ( + generation.type !== 'complete_animation' || + generation.result?.type !== 'complete_animation' + ) { + return '完整动画结果格式无效' + } + return generation.result.frames.length === COMPLETE_ANIMATION_FRAME_COUNT + ? null + : `完整动画应为 ${COMPLETE_ANIMATION_FRAME_COUNT} 帧,实际为 ${generation.result.frames.length} 帧` + } + + return '当前节点不能绑定生成结果' +} + function generationExpectationForNode( run: WorkflowRun, node: WorkflowNode, + direction?: ActionDirection, ): GenerationExpectation | null { - if (node.type === 'character-template') return { type: 'character_template' } + const withDirection = (expectation: T): T => { + return direction === undefined ? expectation : ({ ...expectation, direction } as T) + } + if (node.type === 'character-template') return withDirection({ type: 'character_template' }) if (node.type === 'action-first-frame') { - return { type: 'first_frame', actionType: node.input.type } + return withDirection({ type: 'first_frame', actionType: node.input.type }) } if (node.type === 'action-full-frame') { const methodNode = findSingleDependencyNode(run, node, 'action-generation-method') const firstFrameNode = findSingleDependencyNode(run, methodNode, 'action-first-frame') - return { type: 'complete_animation', actionType: firstFrameNode.input.type } + return withDirection({ type: 'complete_animation', actionType: firstFrameNode.input.type }) } return null } @@ -1356,11 +1769,15 @@ function attachGenerationReference( node: WorkflowNode, taskId: Generation['id'], role: WorkflowGenerationRole, + direction: ActionDirection = 'east', ): WorkflowNode { assertGenerationRoleMatchesNode(node, role) const update = { phase: 'generating' as const, - generations: [...node.generations, { taskId, role }], + generations: [ + ...node.generations, + direction === 'east' ? { taskId, role } : { taskId, role, direction }, + ], error: null, } if (node.type === 'character-template') return { ...node, ...update } @@ -1435,6 +1852,7 @@ function resetNode(node: WorkflowNode): WorkflowNode { generations: [], error: null, selectedImageUrl: null, + selectedImages: undefined, } } if (node.type === 'action-first-frame') { @@ -1445,6 +1863,7 @@ function resetNode(node: WorkflowNode): WorkflowNode { generations: [], error: null, selectedFirstFrameUrl: null, + selectedFirstFrameUrls: undefined, } } if (node.type === 'action-generation-method') { @@ -1467,6 +1886,30 @@ function subscriptionKey(nodeId: string, taskId: string) { return `${nodeId}:${taskId}` } +function generationKey(nodeId: string, role: WorkflowGenerationRole, direction: ActionDirection) { + return `${nodeId}:${role}:${direction}` +} + +function generationReferenceDirection(reference: WorkflowGenerationRef): ActionDirection { + return reference.direction ?? 'east' +} + +function assertSourceDirection( + direction: ActionDirection, + sourceDirections: readonly ActionDirection[], +) { + if (!sourceDirections.includes(direction)) { + throw new Error(`方向 ${direction} 是镜像方向,不能单独生成或确认`) + } +} + +function generationDirectionOf(generation: Generation): ActionDirection { + const result = generation.result + return result && 'direction' in result && result.direction !== undefined + ? result.direction + : 'east' +} + function nonEmpty(value: string, field: string) { const normalized = value.trim() if (!normalized) throw new Error(`${field} 不能为空`) diff --git a/frontend/src/pages/quick-start/index.test.tsx b/frontend/src/pages/quick-start/index.test.tsx index 42ba3ae2..04a8edff 100644 --- a/frontend/src/pages/quick-start/index.test.tsx +++ b/frontend/src/pages/quick-start/index.test.tsx @@ -125,6 +125,8 @@ function serviceFor(run: WorkflowRun | null, overrides: Partial dispose: vi.fn(), confirmCandidate: vi.fn(async () => fallbackRun), getFirstFrameCandidates: vi.fn(async () => []), + getFailedGenerationDirections: vi.fn(async () => []), + retryGenerationDirection: vi.fn(async () => fallbackRun), confirmFirstFrame: vi.fn(async () => fallbackRun), approveReview: vi.fn(async () => fallbackRun), getCharacterInfo: vi.fn(() => ({ characterId: 'character-1', outfitId: 'outfit-1' })), @@ -205,7 +207,6 @@ function renderStateFixture( const candidateUrls = [ 'https://example.test/character-1.png', 'https://example.test/character-2.png', - 'https://example.test/character-3.png', ] const firstFrames = candidateUrls.map((_, index) => ({ index, @@ -524,7 +525,7 @@ describe('QuickStartPage', () => { expect(roleTurn).toBeTruthy() expect(roleTurn?.querySelector('[data-agent-identity]')).toBeNull() expect(roleTurn?.querySelector('[data-agent-copy]')).toBeTruthy() - expect(choices).toHaveLength(3) + expect(choices).toHaveLength(2) expect( Array.from(transcript.querySelectorAll('[data-asset-choice="true"]')).every((asset) => Boolean(asset.closest('[data-agent-turn]')), @@ -624,17 +625,13 @@ describe('QuickStartPage', () => { renderStateFixture('template-selecting') const cards = await screen.findAllByRole('button', { name: /选择角色方案/u }) - expect(cards).toHaveLength(3) + expect(cards).toHaveLength(2) expect(cards.every((card) => card.dataset.assetChoice === 'true')).toBe(true) expect(cards.every((card) => card.querySelectorAll('[data-asset-frame]').length === 1)).toBe( true, ) expect(cards.every((card) => card.dataset.reveal === 'card')).toBe(true) - expect(cards.map((card) => card.style.getPropertyValue('--reveal-index'))).toEqual([ - '0', - '1', - '2', - ]) + expect(cards.map((card) => card.style.getPropertyValue('--reveal-index'))).toEqual(['0', '1']) expect(cards.every((card) => card.querySelector('img'))).toBeTruthy() }) @@ -647,14 +644,14 @@ describe('QuickStartPage', () => { expect(cards.every((card) => card.textContent === '')).toBe(true) }) - it('presents three equal candidate frames without inventing a preferred result', async () => { + it('presents two equal candidate frames without inventing a preferred result', async () => { renderStateFixture('template-selecting') const choices = await screen.findAllByRole('button', { name: /选择角色方案/u }) const resultLayout = choices[0]?.parentElement expect(resultLayout?.getAttribute('data-layout')).toBe('agent-result-set') - expect(resultLayout?.className).toContain('grid-cols-3') + expect(resultLayout?.className).toContain('grid-cols-2') expect(choices.every((choice) => choice.getAttribute('data-result-priority') === null)).toBe( true, ) @@ -662,16 +659,16 @@ describe('QuickStartPage', () => { }) it.each([ - ['template-generating', '角色图生成画布'], - ['first-selecting', '动作首帧候选 1'], - ['complete', '完整动作预览'], - ] as const)('keeps %s on the first-round asset frame grid', async (state, label) => { + ['template-generating', '角色图生成画布', 'grid-cols-3'], + ['first-selecting', '动作首帧候选 1', 'grid-cols-2'], + ['complete', '完整动作预览', 'grid-cols-3'], + ] as const)('keeps %s on the first-round asset frame grid', async (state, label, columns) => { const view = renderStateFixture(state) const asset = await screen.findByRole('img', { name: label }) const frameGrid = asset.closest('[data-layout="agent-result-set"]') expect(frameGrid?.className).toContain('max-w-2xl') - expect(frameGrid?.className).toContain('grid-cols-3') + expect(frameGrid?.className).toContain(columns) view.unmount() }) @@ -722,7 +719,7 @@ describe('QuickStartPage', () => { it('keeps earlier turns visible while the agent conversation moves downward', async () => { renderStateFixture('first-selecting') - await screen.findByLabelText(/已生成 3 个动作起始姿态。 选择一个起始姿态,随后生成完整动作。/u) + await screen.findByLabelText(/已生成 2 个动作起始姿态。 选择一个起始姿态,随后生成完整动作。/u) const transcript = await screen.findByTestId('quick-start-transcript') const topLevelText = Array.from(transcript.children).map( (element) => @@ -733,13 +730,13 @@ describe('QuickStartPage', () => { const roleTurnIndex = topLevelText.findIndex((text) => text.includes('角色方案已确认')) const userActionIndex = topLevelText.findIndex((text) => text.includes('挥手')) const firstFrameTurnIndex = topLevelText.findIndex((text) => - text.includes('已生成 3 个动作起始姿态'), + text.includes('已生成 2 个动作起始姿态'), ) expect(roleTurnIndex).toBeGreaterThanOrEqual(0) expect(roleTurnIndex).toBeLessThan(userActionIndex) expect(userActionIndex).toBeLessThan(firstFrameTurnIndex) expect(screen.getByRole('img', { name: '已选择的角色' })).toBeTruthy() - expect(screen.getAllByRole('img', { name: /动作首帧候选/u })).toHaveLength(3) + expect(screen.getAllByRole('img', { name: /动作首帧候选/u })).toHaveLength(2) }) it('keeps the candidate selected until the action description is sent', async () => { @@ -812,7 +809,7 @@ describe('QuickStartPage', () => { await waitFor(() => expect( - view.container.querySelector('[data-agent-copy][aria-label^="已生成 3 个动作起始姿态"]'), + view.container.querySelector('[data-agent-copy][aria-label^="已生成 2 个动作起始姿态"]'), ).toBeTruthy(), ) const firstFrame = view.getByRole('img', { name: '动作首帧候选 1' }) @@ -1343,6 +1340,28 @@ describe('QuickStartPage', () => { } }) + it('只重试 Quick Start 中失败的源方向', async () => { + const run = actionWorkflow({ firstStatus: 'failed', error: '北方向失败' }) + const firstFrame = run.nodes.find((node) => node.type === 'action-first-frame')! + firstFrame.generations = [ + { taskId: 'first-east', role: 'first_frame' }, + { taskId: 'first-north', role: 'first_frame', direction: 'north' }, + ] + const service = serviceFor(run, { + getFailedGenerationDirections: vi.fn(async () => [ + { nodeId: 'action-first', direction: 'north' as const }, + ]), + retryGenerationDirection: vi.fn(async () => run), + }) + renderAt('/quick-start/run-1', service) + + fireEvent.click(await screen.findByRole('button', { name: '重试北方向' })) + + await waitFor(() => + expect(service.retryGenerationDirection).toHaveBeenCalledWith('action-first', 'north'), + ) + }) + it('saves a completed animation without navigating and exposes both explicit destinations', async () => { const run = actionWorkflow({ fullStatus: 'passed', reviewStatus: 'active' }) const approved = actionWorkflow({ fullStatus: 'passed', reviewStatus: 'passed' }) diff --git a/frontend/src/pages/quick-start/index.tsx b/frontend/src/pages/quick-start/index.tsx index 30a83e4a..ddbcf157 100644 --- a/frontend/src/pages/quick-start/index.tsx +++ b/frontend/src/pages/quick-start/index.tsx @@ -24,6 +24,7 @@ import { KineticCopyCycle, type KineticCopyMessage } from './kinetic-copy-cycle' import { quickStartService, type QuickStartEntryService, + type QuickStartFailedDirection, type QuickStartFrame, type QuickStartSession, } from './service' @@ -76,6 +77,17 @@ const ROLE_IDEA_MESSAGES: readonly KineticCopyMessage[] = [ })), ] +const DIRECTION_LABELS = { + east: '东', + west: '西', + north: '北', + south: '南', + north_east: '东北', + north_west: '西北', + south_east: '东南', + south_west: '西南', +} as const + const ROLE_DEFAULT_MESSAGE: readonly KineticCopyMessage[] = [ { lines: ['用文字塑造你的角色……'], className: 'text-app-ink' }, ] @@ -602,6 +614,8 @@ function QuickStartRun({ const [candidates, setCandidates] = useState([]) const [firstFrameCandidates, setFirstFrameCandidates] = useState([]) const [actionFrames, setActionFrames] = useState([]) + const [failedDirections, setFailedDirections] = useState([]) + const [retryingDirection, setRetryingDirection] = useState(null) const [exportModel, setExportModel] = useState(null) const [publishing, setPublishing] = useState(false) const [confirmingCandidate, setConfirmingCandidate] = useState(false) @@ -709,6 +723,7 @@ function QuickStartRun({ setCandidates([]) setFirstFrameCandidates([]) setActionFrames([]) + setFailedDirections([]) setExportModel(null) return } @@ -718,28 +733,32 @@ function QuickStartRun({ session.getFirstFrameCandidates(), session.getActionFrames(), session.getExportModel(), + session.getFailedGenerationDirections(), ]) - .then(([nextCandidates, nextFirstFrameCandidates, nextFrames, nextExportModel]) => { - if (!active) return - const templateIsSelecting = run.nodes.some( - (node) => - node.type === 'character-template' && - node.status === 'active' && - node.phase === 'selecting', - ) - const firstFrameIsSelecting = run.nodes.some( - (node) => - node.type === 'action-first-frame' && - node.status === 'active' && - node.phase === 'selecting', - ) - if (templateIsSelecting && nextCandidates.length > 0) setCandidates(nextCandidates) - if (firstFrameIsSelecting && nextFirstFrameCandidates.length > 0) { - setFirstFrameCandidates(nextFirstFrameCandidates) - } - if (nextFrames.length > 0) setActionFrames(nextFrames) - setExportModel(nextExportModel) - }) + .then( + ([nextCandidates, nextFirstFrameCandidates, nextFrames, nextExportModel, nextFailed]) => { + if (!active) return + const templateIsSelecting = run.nodes.some( + (node) => + node.type === 'character-template' && + node.status === 'active' && + node.phase === 'selecting', + ) + const firstFrameIsSelecting = run.nodes.some( + (node) => + node.type === 'action-first-frame' && + node.status === 'active' && + node.phase === 'selecting', + ) + if (templateIsSelecting && nextCandidates.length > 0) setCandidates(nextCandidates) + if (firstFrameIsSelecting && nextFirstFrameCandidates.length > 0) { + setFirstFrameCandidates(nextFirstFrameCandidates) + } + if (nextFrames.length > 0) setActionFrames(nextFrames) + setExportModel(nextExportModel) + setFailedDirections(nextFailed) + }, + ) .catch((cause) => { if (active) reportWorkflowError(cause, '读取生成结果失败') }) @@ -919,6 +938,51 @@ function QuickStartRun({ } } + async function retryFailedDirection(item: QuickStartFailedDirection) { + const targetSession = session + if (!targetSession || workflowConflictRef.current) return + const key = `${item.nodeId}:${item.direction}` + setRetryingDirection(key) + clearWorkflowError() + try { + const updated = await targetSession.retryGenerationDirection(item.nodeId, item.direction) + if (!mountedRef.current || activeSessionRef.current !== targetSession) return + setRun(updated) + } catch (cause) { + if (!mountedRef.current || activeSessionRef.current !== targetSession) return + reportWorkflowError(cause, `重试${DIRECTION_LABELS[item.direction]}方向失败`) + } finally { + if (mountedRef.current && activeSessionRef.current === targetSession) { + setRetryingDirection(null) + } + } + } + + function DirectionRetryButtons({ nodeId }: { nodeId: string }) { + const items = failedDirections.filter((item) => item.nodeId === nodeId) + if (items.length === 0) return null + return ( +
+ {items.map((item) => { + const key = `${item.nodeId}:${item.direction}` + return ( + + ) + })} +
+ ) + } + function continueConversation(event: FormEvent) { event.preventDefault() if (workflowConflictRef.current) return @@ -985,7 +1049,7 @@ function QuickStartRun({ <>
{candidates.map((candidateUrl, index) => ( - ))} -
- {selectedImageUrl ? ( - + {groups.map(({ direction, images }) => ( +
+

方向:{directionLabel(direction)}

+
+ {images.map((image, index) => ( + + ))} +
+ {directions.length > 1 ? ( + + ) : null} +
+ ))} + {directions.length === 1 ? ( + singleSelectedImageUrl ? ( + + ) : ( +

先选一张候选,再决定是否把它定为母版。

+ ) ) : ( -

先选一张候选,再决定是否把它定为母版。

+ )} ) @@ -1255,8 +1327,15 @@ function FirstFrameContent({ const branchBusy = input.busyBranches.has(branchKey) const [refining, setRefining] = useState(false) const [adjustmentPrompt, setAdjustmentPrompt] = useState('') - const result = input.generations[generationKey(node.id, 'first_frame')]?.result - const images = result?.type === 'first_frame' ? result.images : [] + const directions = getDirectionProfile(input.project.directionalMovement).sourceDirections + const groups = directions.map((direction) => { + const result = input.generations[generationKey(node.id, 'first_frame', direction)]?.result + return { + direction, + images: result?.type === 'first_frame' ? result.images : [], + } + }) + const allImages = groups.flatMap(({ images }) => images) if (node.status === 'failed') return if (node.phase === 'configuring') { const character = characterOwningOutfit(input.character, node.input.outfitId) @@ -1285,43 +1364,73 @@ function FirstFrameContent({ ) } - if (node.phase === 'selecting' && images.length > 0) { - const selectedImageUrl = images.some((image) => image.url === input.selectedImages[node.id]) - ? input.selectedImages[node.id]! - : null + if (node.phase === 'selecting' && allImages.length > 0) { + const allSelected = groups.every(({ direction, images }) => { + const selected = input.selectedImages[selectionKey(node.id, direction)] + return images.some((image) => image.url === selected) + }) return (
-
- {images.map((image, index) => ( - - ))} -
+ {groups.map(({ direction, images }) => ( +
+

方向:{directionLabel(direction)}

+
+ {images.map((image, index) => ( + + ))} +
+ {directions.length > 1 ? ( + + ) : null} +
+ ))}
) } - if (node.phase === 'completed' && node.selectedFirstFrameUrl) { + if (node.phase === 'completed' && (node.selectedFirstFrameUrl || node.selectedFirstFrameUrls)) { + const selectedImageUrl = + node.selectedFirstFrameUrls?.east ?? node.selectedFirstFrameUrl ?? undefined + if (!selectedImageUrl) return return (
- +
) } - if (node.phase === 'completed' && frames.length) { + if ( + node.phase === 'completed' && + groups.some(({ frames: directionFrames }) => directionFrames.length) + ) { const methodNode = findDependency(input.run, node, 'action-generation-method') const firstFrameNode = methodNode ? findDependency(input.run, methodNode, 'action-first-frame') : null return (
-
- {frames.map((frame, index) => ( - - ))} -
+ {groups.map(({ direction, frames: directionFrames }) => ( +
+

方向:{directionLabel(direction)}

+
+ {directionFrames.map((frame, index) => ( + + ))} +
+
+ ))} {firstFrameNode ? ( ) : null} @@ -1593,6 +1720,20 @@ function WorkflowCard({ data, selected }: NodeProps) { function StatusText({ node, input }: { node: WorkflowNode; input: ProjectionInput }) { const branchKey = branchKeyOf(node, input) const branchBusy = input.busyBranches.has(branchKey) + const generationRole = + node.type === 'character-template' + ? 'character_template' + : node.type === 'action-first-frame' + ? 'first_frame' + : node.type === 'action-full-frame' + ? 'complete_animation' + : null + const failedDirections = generationRole + ? getDirectionProfile(input.project.directionalMovement).sourceDirections.filter( + (direction) => + input.generations[generationKey(node.id, generationRole, direction)]?.status === 'failed', + ) + : [] const resumeBlocked = input.resumeBlocked && node.status === 'active' && node.phase === 'generating' if (node.status === 'failed' || resumeBlocked) { @@ -1601,17 +1742,39 @@ function StatusText({ node, input }: { node: WorkflowNode; input: ProjectionInpu

{node.status === 'failed' ? (node.error ?? '生成失败') : '生成任务恢复失败'}

- + {failedDirections.length > 0 ? ( + failedDirections.map((direction) => ( + + )) + ) : ( + + )}
) } @@ -1644,8 +1807,30 @@ function EditorBoundary({ message }: { message: string }) { * 一个节点可以同时挂多个角色的生成任务,所以字典的键必须带上角色, * 只用节点 ID 会让后读到的那条静默覆盖前一条。已删节点不再读取。 */ -function generationKey(nodeId: WorkflowNode['id'], role: WorkflowGenerationRole) { - return `${nodeId}:${role}` +function generationKey( + nodeId: WorkflowNode['id'], + role: WorkflowGenerationRole, + direction: ActionDirection = 'east', +) { + return direction === 'east' ? `${nodeId}:${role}` : `${nodeId}:${role}:${direction}` +} + +function selectionKey(nodeId: WorkflowNode['id'], direction: ActionDirection) { + return direction === 'east' ? nodeId : `${nodeId}:${direction}` +} + +function directionLabel(direction: ActionDirection) { + const labels: Record = { + east: '东', + west: '西', + north: '北', + south: '南', + north_east: '东北', + north_west: '西北', + south_east: '东南', + south_west: '西南', + } + return labels[direction] } /** diff --git a/frontend/src/pages/workflow-editor/runtime.test.ts b/frontend/src/pages/workflow-editor/runtime.test.ts index 0548ee97..1b70d2f1 100644 --- a/frontend/src/pages/workflow-editor/runtime.test.ts +++ b/frontend/src/pages/workflow-editor/runtime.test.ts @@ -227,6 +227,20 @@ describe('createRealWorkflowEditorSession', () => { expect(update).toHaveBeenCalledWith( expect.objectContaining({ id: '9', + templates: [ + { + direction: 'east', + sourceDirection: null, + mirrorX: false, + imageUrl: 'https://assets.windup.test/master.png', + }, + { + direction: 'west', + sourceDirection: 'east', + mirrorX: true, + imageUrl: null, + }, + ], outfits: [ expect.objectContaining({ id: 'outfit-default', @@ -329,7 +343,7 @@ describe('createRealWorkflowEditorSession', () => { expect(create).not.toHaveBeenCalled() }) - it('已有 Character 和造型时只推进身份母版节点', async () => { + it('已有 Character 和造型时仍持久化已确认的方向母版', async () => { const existing = characterWithOutfitFixture() const { session, create, update } = await createCharacterTemplateSession({ characters: [existing], @@ -340,9 +354,24 @@ describe('createRealWorkflowEditorSession', () => { 'https://assets.windup.test/master.png', ) - expect(character).toEqual(existing) + expect(character).toMatchObject({ + templates: [ + { + direction: 'east', + sourceDirection: null, + mirrorX: false, + imageUrl: 'https://assets.windup.test/master.png', + }, + { + direction: 'west', + sourceDirection: 'east', + mirrorX: true, + imageUrl: null, + }, + ], + }) expect(create).not.toHaveBeenCalled() - expect(update).not.toHaveBeenCalled() + expect(update).toHaveBeenCalledOnce() expect( session.controller.getWorkflow().nodes.find((node) => node.id === 'template'), ).toMatchObject({ status: 'passed', phase: 'completed' }) @@ -832,7 +861,7 @@ describe('createDefaultRealWorkflowEditorSession', () => { project_id: 1, task_type: 'character_image', status: 'failed', - input_payload: { num_images: 3 }, + input_payload: { num_images: 2 }, result: null, error_message: 'provider unavailable', }) diff --git a/frontend/src/pages/workflow-editor/runtime.ts b/frontend/src/pages/workflow-editor/runtime.ts index 34dd7d38..a8061fcb 100644 --- a/frontend/src/pages/workflow-editor/runtime.ts +++ b/frontend/src/pages/workflow-editor/runtime.ts @@ -2,6 +2,7 @@ import type { ActionPreset, Character, CharacterApis, + ActionDirection, GenerationApis, MediaApis, MediaReference, @@ -35,6 +36,7 @@ export interface WorkflowEditorSession { confirmCharacterTemplate( nodeId: CharacterTemplateWorkflowNode['id'], selectedImageUrl: string, + direction?: ActionDirection, ): Promise /** 上传角色生成约束图;页面不接触 multipart 协议或用途枚举。 */ uploadReferenceImage(file: File, signal?: AbortSignal): Promise @@ -93,6 +95,7 @@ export async function createRealWorkflowEditorSession( workflowRunApis: dependencies.workflowRunApis, generationApis: dependencies.generationApis, onAsyncError: reportAsyncError, + directionalMovement: project.directionalMovement, }) const publisher = createCharacterAssetPublisher(dependencies.characterApis) async function shouldRollbackWorkflowChange(isPersisted: (latest: WorkflowRun) => boolean) { diff --git a/frontend/src/pages/workflow-editor/use-workflow-editor-session.ts b/frontend/src/pages/workflow-editor/use-workflow-editor-session.ts index 4db20b6d..57b21b20 100644 --- a/frontend/src/pages/workflow-editor/use-workflow-editor-session.ts +++ b/frontend/src/pages/workflow-editor/use-workflow-editor-session.ts @@ -246,27 +246,56 @@ async function readGenerations( run: WorkflowRun, settled: Map, ): Promise> { - const entries = await Promise.all( - run.nodes - .filter((node) => !node.deletedAt) - .flatMap((node) => - node.generations.map(async (reference) => { - const key = generationKey(node.id, reference.role) - const cached = settled.get(reference.taskId) - if (cached) return [key, cached] as const - const generation = await controller.getGeneration(node.id, reference.role) - if (generation && (generation.status === 'completed' || generation.status === 'failed')) { - settled.set(generation.id, generation) - } - return [key, generation] as const - }), - ), - ) + const entries: Array = [] + const seen = new Set() + for (const node of run.nodes.filter((item) => !item.deletedAt)) { + for (const reference of node.generations) { + const groupKey = `${node.id}:${reference.role}` + if (seen.has(groupKey)) continue + seen.add(groupKey) + const references = node.generations.filter((item) => item.role === reference.role) + const cachedGenerations = references.map((item) => settled.get(item.taskId)) + if ( + cachedGenerations.every((generation): generation is Generation => generation !== undefined) + ) { + for (const generation of cachedGenerations) { + const direction = + generation.result && 'direction' in generation.result + ? (generation.result.direction ?? 'east') + : (references.find((item) => item.taskId === generation.id)?.direction ?? 'east') + entries.push([generationKey(node.id, reference.role, direction), generation]) + } + continue + } + // 真实 Controller 支持批量读取方向任务;旧页面测试和外部适配器可能只实现 + // 单条读取,因此保留兼容分支,避免方向扩展改变原有 Session 接口的最低要求。 + const generations = + typeof controller.getGenerations === 'function' + ? await controller.getGenerations(node.id, reference.role) + : [await controller.getGeneration(node.id, reference.role)].filter( + (generation): generation is Generation => generation !== null, + ) + for (const generation of generations) { + if (generation.status === 'completed' || generation.status === 'failed') { + settled.set(generation.id, generation) + } + const direction = + generation.result && 'direction' in generation.result + ? (generation.result.direction ?? 'east') + : (references.find((item) => item.taskId === generation.id)?.direction ?? 'east') + entries.push([generationKey(node.id, reference.role, direction), generation]) + } + } + } return Object.fromEntries(entries) } -function generationKey(nodeId: WorkflowNode['id'], role: WorkflowGenerationRole) { - return `${nodeId}:${role}` +function generationKey( + nodeId: WorkflowNode['id'], + role: WorkflowGenerationRole, + direction: string = 'east', +) { + return direction === 'east' ? `${nodeId}:${role}` : `${nodeId}:${role}:${direction}` } function errorMessage(cause: unknown, fallback: string) { From 7c2220c32783955c60946cc3b68ec7cc6c320f4f Mon Sep 17 00:00:00 2001 From: xyh202131 <246811510+xyh202131@users.noreply.github.com> Date: Thu, 20 Aug 2026 12:54:45 +0800 Subject: [PATCH 04/15] fix(workflow): address directional review findings --- frontend/src/features/export/index.test.ts | 97 ++++++++++++- frontend/src/features/export/index.ts | 104 +++++++++++--- .../workflow-controller/controller.test.ts | 130 ++++++++++++++++++ .../workflow-controller/controller.ts | 52 +++++-- .../src/pages/quick-start/service.test.ts | 51 +++++++ frontend/src/pages/quick-start/service.ts | 23 ++-- .../src/pages/workflow-editor/runtime.test.ts | 73 ++++++++++ frontend/src/pages/workflow-editor/runtime.ts | 7 +- 8 files changed, 486 insertions(+), 51 deletions(-) diff --git a/frontend/src/features/export/index.test.ts b/frontend/src/features/export/index.test.ts index f4bf3d15..47f8a6e8 100644 --- a/frontend/src/features/export/index.test.ts +++ b/frontend/src/features/export/index.test.ts @@ -1,6 +1,12 @@ import { describe, expect, it } from 'vitest' -import type { Character, CharacterApis, Generation, WorkflowRun } from '@/entities' +import type { + Character, + CharacterApis, + DirectionalMovement, + Generation, + WorkflowRun, +} from '@/entities' import * as exportFeature from './index' @@ -10,7 +16,9 @@ interface ExportFeatureModule { character: Character workflow: WorkflowRun reviewNodeId: string - generation: Generation + generation?: Generation + generations?: readonly Generation[] + directionalMovement?: DirectionalMovement }): Promise } } @@ -45,7 +53,7 @@ describe('Character asset publisher', () => { }) expect(published.outfits[0]?.actions).toEqual([ expect.objectContaining({ id: 'idle', type: 'idle' }), - { + expect.objectContaining({ id: 'action-walk', outfitId: 'outfit-default', name: '行走', @@ -57,7 +65,7 @@ describe('Character asset publisher', () => { { index: 0, imageUrl: 'https://assets.windup.test/walk-01.png', durationMs: 125 }, { index: 1, imageUrl: 'https://assets.windup.test/walk-02.png', durationMs: 80 }, ], - }, + }), ]) }) @@ -81,6 +89,66 @@ describe('Character asset publisher', () => { expect(retried.outfits[0]?.actions.map((action) => action.id)).toEqual(['idle', 'action-walk']) }) + it('publishes every real and mirrored direction into one action', async () => { + const publisher = ( + exportFeature as unknown as ExportFeatureModule + ).createCharacterAssetPublisher({ + async update(character) { + return structuredClone(character) + }, + }) + const workflow = workflowFixture() + const fullFrame = workflow.nodes.find((node) => node.type === 'action-full-frame')! + fullFrame.generations = [ + { taskId: 'generation-east', role: 'complete_animation', direction: 'east' }, + { taskId: 'generation-north', role: 'complete_animation', direction: 'north' }, + { taskId: 'generation-south', role: 'complete_animation', direction: 'south' }, + ] + + const published = await publisher.publishReviewedAction({ + character: characterFixture(), + workflow, + reviewNodeId: 'action-walk:review', + generations: [ + directionalAnimationFixture('generation-east', 'east', 'east'), + directionalAnimationFixture('generation-north', 'north', 'north'), + directionalAnimationFixture('generation-south', 'south', 'south'), + ], + directionalMovement: 'four-way', + }) + + expect(published.outfits[0]?.actions.at(-1)?.sequences).toEqual([ + { + direction: 'east', + sourceDirection: null, + mirrorX: false, + frameCount: 1, + frames: [{ index: 0, imageUrl: 'east-0.png', durationMs: 80 }], + }, + { + direction: 'west', + sourceDirection: 'east', + mirrorX: true, + frameCount: 1, + frames: [], + }, + { + direction: 'north', + sourceDirection: null, + mirrorX: false, + frameCount: 1, + frames: [{ index: 0, imageUrl: 'north-0.png', durationMs: 80 }], + }, + { + direction: 'south', + sourceDirection: null, + mirrorX: false, + frameCount: 1, + frames: [{ index: 0, imageUrl: 'south-0.png', durationMs: 80 }], + }, + ]) + }) + it('rejects a completed task from another project', async () => { const publisher = createRejectingPublisher() @@ -108,7 +176,7 @@ describe('Character asset publisher', () => { reviewNodeId: 'action-walk:review', generation: wrongType, }), - ).rejects.toThrow('完整动画生成结果不可发布') + ).rejects.toThrow('完整动画方向 east 的生成结果不可发布') }) }) @@ -253,3 +321,22 @@ function completeAnimationFixture(): Generation<'complete_animation'> { }, } } + +function directionalAnimationFixture( + id: string, + direction: 'east' | 'north' | 'south', + prefix: string, +): Generation<'complete_animation'> { + return { + id, + projectId: '42', + type: 'complete_animation', + status: 'completed', + error: null, + result: { + type: 'complete_animation', + direction, + frames: [{ index: 0, url: `${prefix}-0.png`, durationMs: 80 }], + }, + } +} diff --git a/frontend/src/features/export/index.ts b/frontend/src/features/export/index.ts index 5d8618f5..2df59d21 100644 --- a/frontend/src/features/export/index.ts +++ b/frontend/src/features/export/index.ts @@ -1,17 +1,23 @@ import type { Action, + ActionSequence, Character, CharacterApis, + DirectionalMovement, Generation, WorkflowNode, WorkflowRun, } from '@/entities' +import { getDirectionProfile, resolveActionDirection } from '@/entities' export interface PublishReviewedActionInput { character: Character workflow: WorkflowRun reviewNodeId: string - generation: Generation + /** 新工作流传入全部真实源方向;旧调用仍可只传 east generation。 */ + generations?: readonly Generation[] + generation?: Generation + directionalMovement?: DirectionalMovement } export interface CharacterAssetPublisher { @@ -26,7 +32,14 @@ export function createCharacterAssetPublisher( characterApis: Pick, ): CharacterAssetPublisher { return { - async publishReviewedAction({ character, workflow, reviewNodeId, generation }) { + async publishReviewedAction({ + character, + workflow, + reviewNodeId, + generations, + generation, + directionalMovement = 'single', + }) { if (character.workflowRunId !== workflow.id || character.projectId !== workflow.projectId) { throw new Error('Character 与当前 WorkflowRun 不匹配') } @@ -39,18 +52,26 @@ export function createCharacterAssetPublisher( if (fullFrameNode.status !== 'passed' || fullFrameNode.phase !== 'completed') { throw new Error('完整动画尚未完成') } - if (generation.projectId !== workflow.projectId) { - throw new Error('Generation 与当前 WorkflowRun 不匹配') - } - if ( - generation.id !== - fullFrameNode.generations.find((item) => item.role === 'complete_animation')?.taskId || - generation.type !== 'complete_animation' || - generation.status !== 'completed' || - generation.result?.type !== 'complete_animation' || - generation.result.frames.length === 0 - ) { - throw new Error('完整动画生成结果不可发布') + const generationItems = generations ?? (generation ? [generation] : []) + const profile = getDirectionProfile(directionalMovement) + for (const direction of profile.sourceDirections) { + const reference = fullFrameNode.generations.find( + (item) => item.role === 'complete_animation' && (item.direction ?? 'east') === direction, + ) + const item = generationItems.find((candidate) => candidate.id === reference?.taskId) + if (item?.projectId !== workflow.projectId) { + throw new Error('Generation 与当前 WorkflowRun 不匹配') + } + if ( + !reference || + item.type !== 'complete_animation' || + item.status !== 'completed' || + item.result?.type !== 'complete_animation' || + (item.result.direction ?? 'east') !== direction || + item.result.frames.length === 0 + ) { + throw new Error(`完整动画方向 ${direction} 的生成结果不可发布`) + } } const methodNode = findSingleDependency(workflow, fullFrameNode, 'action-generation-method') @@ -63,6 +84,8 @@ export function createCharacterAssetPublisher( ) if (outfitIndex < 0) throw new Error('动作所属造型不存在') + const sequences = createActionSequences(generationItems, directionalMovement) + const eastSequence = sequences.find((sequence) => sequence.direction === 'east')! const action: Action = { id: firstFrameNode.id, outfitId: firstFrameNode.input.outfitId, @@ -70,12 +93,9 @@ export function createCharacterAssetPublisher( type: firstFrameNode.input.type, loop: firstFrameNode.input.type === 'idle' || firstFrameNode.input.type === 'walk', fps: firstFrameNode.input.fps, - frameCount: generation.result.frames.length, - frames: generation.result.frames.map((frame) => ({ - index: frame.index, - imageUrl: frame.url, - durationMs: frame.durationMs, - })), + frameCount: eastSequence.frameCount, + frames: eastSequence.frames, + sequences, } const targetOutfit = character.outfits[outfitIndex]! const actionIndex = targetOutfit.actions.findIndex((item) => item.id === action.id) @@ -93,6 +113,50 @@ export function createCharacterAssetPublisher( } } +export function createActionSequences( + generations: readonly Generation[], + directionalMovement: DirectionalMovement, +): ActionSequence[] { + const profile = getDirectionProfile(directionalMovement) + const sources = new Map( + profile.sourceDirections.map((direction) => { + const generation = generations.find( + (item) => + item.type === 'complete_animation' && + item.status === 'completed' && + item.result?.type === 'complete_animation' && + (item.result.direction ?? 'east') === direction, + ) + if ( + generation?.type !== 'complete_animation' || + generation.result?.type !== 'complete_animation' || + generation.result.frames.length === 0 + ) { + throw new Error(`完整动画方向 ${direction} 的生成结果不可发布`) + } + return [direction, generation.result.frames] as const + }), + ) + return profile.logicalDirections.map((direction) => { + const resolution = resolveActionDirection(direction) + const frames = sources.get(resolution.sourceDirection) + if (!frames) throw new Error(`完整动画缺少方向 ${resolution.sourceDirection}`) + return { + direction, + sourceDirection: resolution.mirrorX ? resolution.sourceDirection : null, + mirrorX: resolution.mirrorX, + frameCount: frames.length, + frames: resolution.mirrorX + ? [] + : frames.map((frame) => ({ + index: frame.index, + imageUrl: frame.url, + durationMs: frame.durationMs, + })), + } + }) +} + function findNode( workflow: WorkflowRun, nodeId: string, diff --git a/frontend/src/features/workflow-controller/controller.test.ts b/frontend/src/features/workflow-controller/controller.test.ts index c7eeb1ce..69d198e1 100644 --- a/frontend/src/features/workflow-controller/controller.test.ts +++ b/frontend/src/features/workflow-controller/controller.test.ts @@ -1139,6 +1139,53 @@ describe('WorkflowController', () => { }) }) + it('非东向任务失败时保留服务端错误而不误报方向不一致', async () => { + const references = (['east', 'north', 'south'] as const).map((direction) => ({ + taskId: `task-${direction}`, + role: 'character_template' as const, + direction, + })) + const run = createRun([ + setupNode({ status: 'passed', phase: 'completed' }), + templateNode({ status: 'active', phase: 'generating', generations: references }), + ]) + const { controller, generation } = createController(run, 'four-way') + generation.snapshots.set('task-east', { + id: 'task-east', + projectId: '1', + type: 'character_template', + status: 'running', + result: null, + error: null, + }) + generation.snapshots.set('task-south', { + id: 'task-south', + projectId: '1', + type: 'character_template', + status: 'running', + result: null, + error: null, + }) + + await controller.applyGenerationResult({ + nodeId: 'template-1', + taskId: 'task-north', + generation: { + id: 'task-north', + projectId: '1', + type: 'character_template', + status: 'failed', + result: null, + error: 'north provider failed', + }, + }) + + expect(controller.getWorkflow().nodes[1]).toMatchObject({ + status: 'failed', + error: 'north provider failed', + }) + }) + it('只重试失败方向并保留其它方向的任务引用', async () => { const { controller, generation } = createController(createRun(), 'four-way') @@ -2396,6 +2443,89 @@ describe('WorkflowController', () => { }) }) + it('四向角色母版微调分别使用同方向已确认图片', async () => { + const run = createRun([ + setupNode({ status: 'passed', phase: 'completed' }), + templateNode({ + status: 'passed', + phase: 'completed', + selectedImageUrl: 'east-template.png', + selectedImages: { + east: 'east-template.png', + north: 'north-template.png', + south: 'south-template.png', + }, + }), + ]) + const { controller, generation } = createController(run, 'four-way') + + await controller.regenerateCharacterTemplate('template-1', { + spriteWidth: 64, + spriteHeight: 96, + mode: 'refine', + adjustmentPrompt: '增加轮廓光', + }) + + expect( + vi.mocked(generation.apis.create).mock.calls.map(([input]) => ({ + direction: input.direction, + referenceMedia: input.referenceMedia, + })), + ).toEqual([ + { direction: 'east', referenceMedia: ['east-template.png'] }, + { direction: 'north', referenceMedia: ['north-template.png'] }, + { direction: 'south', referenceMedia: ['south-template.png'] }, + ]) + }) + + it('四向动作首帧微调分别使用同方向已确认图片', async () => { + const run = createRun([ + setupNode({ status: 'passed', phase: 'completed' }), + templateNode({ + status: 'passed', + phase: 'completed', + selectedImageUrl: 'east-template.png', + selectedImages: { + east: 'east-template.png', + north: 'north-template.png', + south: 'south-template.png', + }, + }), + firstFrameNode({ + status: 'passed', + phase: 'completed', + selectedFirstFrameUrl: 'east-frame.png', + selectedFirstFrameUrls: { + east: 'east-frame.png', + north: 'north-frame.png', + south: 'south-frame.png', + }, + }), + generationMethodNode(), + fullFrameNode(), + reviewNode(), + ]) + const { controller, generation } = createController(run, 'four-way') + + await controller.regenerateFirstFrame('action-walk', { + spriteWidth: 64, + spriteHeight: 96, + mode: 'refine', + adjustmentPrompt: '增加轮廓光', + }) + + expect( + vi.mocked(generation.apis.create).mock.calls.map(([input]) => ({ + direction: input.direction, + referenceMedia: input.referenceMedia, + })), + ).toEqual([ + { direction: 'east', referenceMedia: ['east-frame.png'] }, + { direction: 'north', referenceMedia: ['north-frame.png'] }, + { direction: 'south', referenceMedia: ['south-frame.png'] }, + ]) + }) + it('动作首帧重新生成沿用原始输入且不携带上一版图片', async () => { const previousImage = 'https://img/first-frame-previous.png' const run = createRun([ diff --git a/frontend/src/features/workflow-controller/controller.ts b/frontend/src/features/workflow-controller/controller.ts index 09318d77..f99177cc 100644 --- a/frontend/src/features/workflow-controller/controller.ts +++ b/frontend/src/features/workflow-controller/controller.ts @@ -44,6 +44,8 @@ export interface GenerateCharacterTemplateOptions { spriteHeight: number /** 重生成时用上一版图片约束本次结果;不覆盖角色设定中的原始参考素材。 */ sourceImageUrl?: GeneratedImage['url'] + /** 多方向微调时,每个源方向必须使用自己上一版的已确认图片。 */ + sourceImageUrls?: Partial> /** 只影响本次请求的 prompt 覆盖值;不改写角色设定节点的原始输入。 */ prompt?: string /** 手动编辑器提交时覆盖 configuring 节点的初始输入;节点通过后不再改写。 */ @@ -61,6 +63,8 @@ export interface GenerateFirstFrameOptions { spriteHeight: number /** 重生成时用上一版首帧约束本次结果;不改写已确认的角色母版。 */ sourceImageUrl?: GeneratedImage['url'] + /** 多方向微调时,每个源方向必须使用自己上一版的已确认首帧。 */ + sourceImageUrls?: Partial> /** 只影响本次请求的 prompt 覆盖值;不改写动作节点的原始输入。 */ prompt?: string } @@ -419,7 +423,6 @@ export function createWorkflowController({ nodeId: CharacterSetupWorkflowNode['id'], options: GenerateCharacterTemplateOptions, ): Promise { - const sourceImage = generatedImageReference(options.sourceImageUrl) const before = requireWorkflow() const setupBefore = findNode(before, nodeId) if (setupBefore.type !== 'character-setup') throw new Error('目标节点不是角色设定') @@ -459,6 +462,9 @@ export function createWorkflowController({ throw new Error('角色母版节点当前不能开始生成') } const setupNode = findSingleDependencyNode(run, node, 'character-setup') + const sourceImage = generatedImageReference( + options.sourceImageUrls?.[direction] ?? options.sourceImageUrl, + ) const input: CharacterTemplateGenerationInput = { type: 'character_template', projectId: run.projectId, @@ -610,7 +616,6 @@ export function createWorkflowController({ nodeId: ActionFirstFrameWorkflowNode['id'], options: GenerateFirstFrameOptions, ) { - const sourceImage = generatedImageReference(options.sourceImageUrl) const before = requireWorkflow() const targetNode = findNode(before, nodeId) if (targetNode.type !== 'action-first-frame') throw new Error('目标节点不是动作首帧') @@ -639,6 +644,9 @@ export function createWorkflowController({ direction, ) if (!characterTemplateReference) throw new Error(`角色母版尚未确认方向 ${direction}`) + const sourceImage = generatedImageReference( + options.sourceImageUrls?.[direction] ?? options.sourceImageUrl, + ) const input: FirstFrameGenerationInput = { type: 'first_frame', projectId: run.projectId, @@ -676,7 +684,20 @@ export function createWorkflowController({ } const setupNode = findSingleDependencyNode(before, templateNode, 'character-setup') const prompt = adjustedPrompt(setupNode.input.prompt, options) - const sourceImageUrl = options.mode === 'refine' ? templateNode.selectedImageUrl : undefined + const sourceImageUrls = + options.mode === 'refine' + ? Object.fromEntries( + sourceDirections.map((direction) => { + const imageUrl = selectedDirectionUrl( + templateNode.selectedImages, + templateNode.selectedImageUrl, + direction, + ) + if (!imageUrl) throw new Error(`角色母版尚未确认方向 ${direction}`) + return [direction, imageUrl] + }), + ) + : undefined const keys = sourceDirections.map((direction) => generationKey(nodeId, 'character_template', direction), ) @@ -689,7 +710,7 @@ export function createWorkflowController({ return generateCharacterTemplate(setupNode.id, { spriteWidth: options.spriteWidth, spriteHeight: options.spriteHeight, - sourceImageUrl, + sourceImageUrls, prompt, }) }) @@ -713,8 +734,20 @@ export function createWorkflowController({ } const basePrompt = firstFrameNode.input.prompt?.trim() || firstFrameNode.input.name const prompt = adjustedPrompt(basePrompt, options) - const sourceImageUrl = - options.mode === 'refine' ? firstFrameNode.selectedFirstFrameUrl : undefined + const sourceImageUrls = + options.mode === 'refine' + ? Object.fromEntries( + sourceDirections.map((direction) => { + const imageUrl = selectedDirectionUrl( + firstFrameNode.selectedFirstFrameUrls, + firstFrameNode.selectedFirstFrameUrl, + direction, + ) + if (!imageUrl) throw new Error(`动作首帧尚未确认方向 ${direction}`) + return [direction, imageUrl] + }), + ) + : undefined const keys = sourceDirections.map((direction) => generationKey(nodeId, 'first_frame', direction), ) @@ -727,7 +760,7 @@ export function createWorkflowController({ return generateFirstFrame(nodeId, { spriteWidth: options.spriteWidth, spriteHeight: options.spriteHeight, - sourceImageUrl, + sourceImageUrls, prompt, }) }) @@ -1334,7 +1367,10 @@ export function createWorkflowController({ // 方向是任务契约的一部分。服务端返回了错误方向时不能把它静默挂到当前方向, // 否则四向/八向资产会在导入 Playtest 后出现“名称对得上、画面却错位”的问题。 - if (generationDirectionOf(generation) !== generationReferenceDirection(reference)) { + if ( + generation.status === 'completed' && + generationDirectionOf(generation) !== generationReferenceDirection(reference) + ) { return persist((run) => { const currentNode = findNode(run, nodeId) return currentNode.status === 'active' diff --git a/frontend/src/pages/quick-start/service.test.ts b/frontend/src/pages/quick-start/service.test.ts index bdbde2c5..94906d3c 100644 --- a/frontend/src/pages/quick-start/service.test.ts +++ b/frontend/src/pages/quick-start/service.test.ts @@ -708,6 +708,57 @@ describe('createQuickStartService', () => { ]) }) + it('Quick Start 四向审核发布时保留全部方向序列', async () => { + const run = actionRun() + const fullFrame = run.nodes.find((node) => node.type === 'action-full-frame')! + fullFrame.generations = [ + { taskId: 'task-east', role: 'complete_animation', direction: 'east' }, + { taskId: 'task-north', role: 'complete_animation', direction: 'north' }, + { taskId: 'task-south', role: 'complete_animation', direction: 'south' }, + ] + const generationApis: GenerationApis = { + create: vi.fn(), + get: vi.fn(async (projectId, id) => { + const direction = id.replace('task-', '') as 'east' | 'north' | 'south' + return { + id, + projectId, + type: 'complete_animation' as const, + status: 'completed' as const, + result: { + type: 'complete_animation' as const, + direction, + frames: [{ index: 0, url: `${direction}.png`, durationMs: 80 }], + }, + error: null, + } + }), + subscribe: vi.fn(() => () => undefined), + } + let character = characterWithDefaultOutfit(run.id) + const characterApis = mutableCharacterApis( + () => character, + (value) => (character = value), + ) + const service = createQuickStartService({ + workflowRunApis: createWorkflowRunApis([run]), + generationApis, + characterApis, + prepareProject: vi.fn(), + projectApis: projectReader({ width: 256, height: 256 }, 'four-way'), + }) + + const session = await service.open(run.id) + await session.approveReview() + + expect(character.outfits[0]?.actions[0]?.sequences?.map((item) => item.direction)).toEqual([ + 'east', + 'west', + 'north', + 'south', + ]) + }) + it.each([new Error('WorkflowRun 回读失败'), '回读失败'])( '审核冲突后无法回读 Run 时保留幂等动作并上报对账错误', async (reconcileCause) => { diff --git a/frontend/src/pages/quick-start/service.ts b/frontend/src/pages/quick-start/service.ts index 2c1be6cc..4688b56b 100644 --- a/frontend/src/pages/quick-start/service.ts +++ b/frontend/src/pages/quick-start/service.ts @@ -26,6 +26,7 @@ import { getApiAccessToken, recoverApiUnauthorized, resolveApiBaseUrl } from '@/ import { createEventStreamSubscriber } from '@/shared/api/stream' import { createWorkflowController, type WorkflowController } from '@/features/workflow-controller' import { createProgressiveExportModel, type ExportPackageModel } from '@/features/export-package' +import { createActionSequences } from '@/features/export' /** 页面不直接拼接后端字段;只负责准备项目约束。 */ export type PrepareQuickStartProject = ( @@ -834,15 +835,10 @@ export function createQuickStartService({ throw new Error('完整动画当前不能通过审核') } - const generation = await controller.getGeneration(fullFrame.id, 'complete_animation') - if ( - !generation || - generation.status !== 'completed' || - generation.type !== 'complete_animation' || - generation.result?.type !== 'complete_animation' - ) { - throw new Error('完整动画结果尚未就绪') - } + const generations = await controller.getGenerations(fullFrame.id, 'complete_animation') + const directionalMovement = projectDirectionalMovements.get(run.projectId) ?? 'single' + const sequences = createActionSequences(generations, directionalMovement) + const eastSequence = sequences.find((sequence) => sequence.direction === 'east')! const info = await resolveCharacterInfo(controller) if (!info) throw new Error('WorkflowRun 缺少角色或造型绑定') const firstFrame = latestActionFirstFrame(controller.getWorkflow()) @@ -860,12 +856,9 @@ export function createQuickStartService({ loop: true, type: firstFrame.input.type, fps: firstFrame.input.fps, - frameCount: generation.result.frames.length, - frames: generation.result.frames.map((frame) => ({ - index: frame.index, - imageUrl: frame.url, - durationMs: frame.durationMs, - })), + frameCount: eastSequence.frameCount, + frames: eastSequence.frames, + sequences, } const publishedCharacter = await characterApis.update({ ...character, diff --git a/frontend/src/pages/workflow-editor/runtime.test.ts b/frontend/src/pages/workflow-editor/runtime.test.ts index 1b70d2f1..18fa8bf0 100644 --- a/frontend/src/pages/workflow-editor/runtime.test.ts +++ b/frontend/src/pages/workflow-editor/runtime.test.ts @@ -521,6 +521,64 @@ describe('createRealWorkflowEditorSession', () => { ).toMatchObject({ status: 'passed', phase: 'completed' }) }) + it('四向审核发布时把全部方向写入同一个 Character 动作', async () => { + const workflow = reviewingWorkflowFixture() + const fullFrame = workflow.nodes.find((node) => node.type === 'action-full-frame')! + fullFrame.generations = [ + { taskId: 'generation-east', role: 'complete_animation', direction: 'east' }, + { taskId: 'generation-north', role: 'complete_animation', direction: 'north' }, + { taskId: 'generation-south', role: 'complete_animation', direction: 'south' }, + ] + const generations = new Map([ + ['generation-east', directionalAnimationFixture('generation-east', 'east')], + ['generation-north', directionalAnimationFixture('generation-north', 'north')], + ['generation-south', directionalAnimationFixture('generation-south', 'south')], + ]) + const session = await createRealWorkflowEditorSession('42', { + workflowRunApis: { + create: vi.fn(), + get: vi.fn().mockResolvedValue(workflow), + update: vi.fn(async (run) => ({ ...structuredClone(run), version: run.version + 1 })), + remove: vi.fn(), + }, + generationApis: { + create: vi.fn() as GenerationApis['create'], + get: vi.fn(async (_projectId, id) => structuredClone(generations.get(id)!)), + subscribe: vi.fn(() => () => undefined), + }, + mediaApis: { upload: vi.fn() }, + render3d: stubRender3DApis(), + projectApis: { + get: vi.fn().mockResolvedValue({ + ...projectFixture(), + directionalMovement: 'four-way', + }), + }, + characterApis: { + listByProject: vi.fn().mockResolvedValue({ + items: [characterWithOutfitFixture()], + total: 1, + page: 1, + pageSize: 100, + }), + create: vi.fn(), + get: vi.fn().mockResolvedValue(characterWithOutfitFixture()), + update: vi.fn(async (character) => structuredClone(character)), + remove: vi.fn(), + }, + onAsyncError: vi.fn(), + }) + + const published = await session.publishReviewedAction('action-walk:review') + + expect(published.outfits[0]?.actions[0]?.sequences?.map((item) => item.direction)).toEqual([ + 'east', + 'west', + 'north', + 'south', + ]) + }) + it('拒绝发布缺少动作首帧依赖的完整动画', async () => { const workflow = reviewingWorkflowFixture() workflow.nodes = workflow.nodes.filter((node) => node.type !== 'action-first-frame') @@ -1131,6 +1189,21 @@ function completeAnimationFixture(): Generation<'complete_animation'> { } } +function directionalAnimationFixture( + id: string, + direction: 'east' | 'north' | 'south', +): Generation<'complete_animation'> { + return { + ...completeAnimationFixture(), + id, + result: { + type: 'complete_animation', + direction, + frames: [{ index: 0, url: `${direction}.png`, durationMs: 80 }], + }, + } +} + function apiSuccess(data: unknown, extra: Record = {}) { return new Response(JSON.stringify({ code: 200, message: 'success', data, ...extra }), { status: 200, diff --git a/frontend/src/pages/workflow-editor/runtime.ts b/frontend/src/pages/workflow-editor/runtime.ts index a8061fcb..e43f22b8 100644 --- a/frontend/src/pages/workflow-editor/runtime.ts +++ b/frontend/src/pages/workflow-editor/runtime.ts @@ -180,15 +180,16 @@ export async function createRealWorkflowEditorSession( if (!firstFrameNode || firstFrameNode.type !== 'action-first-frame') { throw new Error('完整动画缺少动作首帧节点') } - const generation = await controller.getGeneration(fullFrameNodeId, 'complete_animation') - if (!generation) throw new Error('完整动画生成结果不存在') + const generations = await controller.getGenerations(fullFrameNodeId, 'complete_animation') + if (generations.length === 0) throw new Error('完整动画生成结果不存在') const originalCharacter = structuredClone(currentCharacter) const publishedCharacter = await publisher.publishReviewedAction({ character: originalCharacter, workflow: currentWorkflow, reviewNodeId, - generation, + generations, + directionalMovement: project.directionalMovement, }) try { await controller.approveReview(reviewNodeId) From 2e845db30db79fa51c27f975208f6c25c13390a3 Mon Sep 17 00:00:00 2001 From: xyh202131 <246811510+xyh202131@users.noreply.github.com> Date: Thu, 20 Aug 2026 13:03:42 +0800 Subject: [PATCH 05/15] test(workflow): cover directional retry paths --- .../workflow-controller/controller.test.ts | 171 ++++++++++++++++++ 1 file changed, 171 insertions(+) diff --git a/frontend/src/features/workflow-controller/controller.test.ts b/frontend/src/features/workflow-controller/controller.test.ts index 69d198e1..86527a12 100644 --- a/frontend/src/features/workflow-controller/controller.test.ts +++ b/frontend/src/features/workflow-controller/controller.test.ts @@ -1231,6 +1231,177 @@ describe('WorkflowController', () => { }) }) + it('动作首帧只重试失败方向并使用同方向角色母版', async () => { + const run = createRun([ + setupNode({ status: 'passed', phase: 'completed' }), + templateNode({ + status: 'passed', + phase: 'completed', + selectedImageUrl: 'east-template.png', + selectedImages: { + east: 'east-template.png', + north: 'north-template.png', + south: 'south-template.png', + }, + }), + firstFrameNode({ + status: 'failed', + phase: 'generating', + generations: [ + { taskId: 'task-east', role: 'first_frame' }, + { taskId: 'task-north', role: 'first_frame', direction: 'north' }, + { taskId: 'task-south', role: 'first_frame', direction: 'south' }, + ], + selectedFirstFrameUrl: 'east-frame.png', + selectedFirstFrameUrls: { + east: 'east-frame.png', + north: 'north-frame.png', + south: 'south-frame.png', + }, + error: 'north provider failed', + }), + generationMethodNode(), + fullFrameNode(), + reviewNode(), + ]) + const { controller, generation } = createController(run, 'four-way') + for (const direction of ['east', 'south'] as const) { + generation.snapshots.set(`task-${direction}`, { + id: `task-${direction}`, + projectId: '1', + type: 'first_frame', + status: 'completed', + result: { + type: 'first_frame', + direction, + images: [{ url: `${direction}-frame.png` }, { url: `${direction}-alt.png` }], + }, + error: null, + }) + } + + await controller.retryGenerationDirection('action-walk', 'north', { + spriteWidth: 64, + spriteHeight: 64, + }) + + expect(generation.apis.create).toHaveBeenCalledTimes(1) + expect(generation.apis.create).toHaveBeenCalledWith({ + type: 'first_frame', + projectId: '1', + actionType: 'walk', + prompt: '行走', + spriteWidth: 64, + spriteHeight: 64, + referenceMedia: ['north-template.png'], + direction: 'north', + }) + const retriedFirstFrame = controller.getWorkflow().nodes[2] + if (retriedFirstFrame?.type !== 'action-first-frame') { + throw new Error('测试运行缺少动作首帧节点') + } + expect(retriedFirstFrame).toMatchObject({ + status: 'active', + phase: 'generating', + selectedFirstFrameUrl: 'east-frame.png', + }) + expect(retriedFirstFrame.selectedFirstFrameUrls).toEqual({ + east: 'east-frame.png', + south: 'south-frame.png', + }) + expect(retriedFirstFrame.generations).toEqual([ + { taskId: 'task-east', role: 'first_frame' }, + { taskId: 'task-south', role: 'first_frame', direction: 'south' }, + { taskId: 'task-1', role: 'first_frame', direction: 'north' }, + ]) + }) + + it('完整动画只重试失败方向并沿用同方向首帧', async () => { + const run = createRun([ + setupNode({ + status: 'passed', + phase: 'completed', + input: { prompt: '像素骑士', referenceMedia: [], characterId: 'character-1' }, + }), + templateNode({ + status: 'passed', + phase: 'completed', + selectedImageUrl: 'east-template.png', + }), + firstFrameNode({ + status: 'passed', + phase: 'completed', + selectedFirstFrameUrl: 'east-frame.png', + selectedFirstFrameUrls: { + east: 'east-frame.png', + north: 'north-frame.png', + south: 'south-frame.png', + }, + }), + generationMethodNode({ status: 'passed', phase: 'completed', method: 'video-cropping' }), + fullFrameNode({ + status: 'failed', + phase: 'generating', + generations: [ + { taskId: 'task-east', role: 'complete_animation' }, + { taskId: 'task-north', role: 'complete_animation', direction: 'north' }, + { taskId: 'task-south', role: 'complete_animation', direction: 'south' }, + ], + error: 'north provider failed', + }), + reviewNode(), + ]) + const { controller, generation } = createController(run, 'four-way') + for (const direction of ['east', 'south'] as const) { + generation.snapshots.set(`task-${direction}`, { + id: `task-${direction}`, + projectId: '1', + type: 'complete_animation', + status: 'completed', + result: { + type: 'complete_animation', + direction, + frames: [{ index: 0, url: `${direction}-frame.png`, durationMs: 80 }], + }, + error: null, + }) + } + + await controller.retryGenerationDirection('action-walk:action-full-frame', 'north', { + spriteWidth: 64, + spriteHeight: 64, + referenceMedia: ['north-reference.png' as never], + }) + + expect(generation.apis.create).toHaveBeenCalledTimes(1) + expect(generation.apis.create).toHaveBeenCalledWith({ + type: 'complete_animation', + projectId: '1', + characterId: 'character-1', + outfitId: 'outfit-1', + method: 'video-cropping', + actionType: 'walk', + firstFrameUrl: 'north-frame.png', + prompt: null, + referenceMedia: ['north-reference.png'], + direction: 'north', + }) + const retriedFullFrame = controller.getWorkflow().nodes[4] + if (retriedFullFrame?.type !== 'action-full-frame') { + throw new Error('测试运行缺少完整动画节点') + } + expect(retriedFullFrame).toMatchObject({ + status: 'active', + phase: 'generating', + error: null, + }) + expect(retriedFullFrame.generations).toEqual([ + { taskId: 'task-east', role: 'complete_animation' }, + { taskId: 'task-south', role: 'complete_animation', direction: 'south' }, + { taskId: 'task-1', role: 'complete_animation', direction: 'north' }, + ]) + }) + it('刷新后恢复其它方向订阅失败时仍保留新建的重试任务引用', async () => { const run = createRun([ setupNode({ status: 'passed', phase: 'completed' }), From 3efce4c935130daec5edc34ec5f9f093d5cf9927 Mon Sep 17 00:00:00 2001 From: xyh202131 <246811510+xyh202131@users.noreply.github.com> Date: Thu, 20 Aug 2026 13:16:49 +0800 Subject: [PATCH 06/15] test(workflow): meet directional patch coverage --- frontend/src/features/export/index.test.ts | 9 ++ .../workflow-controller/controller.test.ts | 61 ++++++++ frontend/src/pages/quick-start/index.test.tsx | 17 +++ .../src/pages/quick-start/service.test.ts | 143 ++++++++++++++++++ .../src/pages/workflow-editor/index.test.tsx | 64 ++++++++ 5 files changed, 294 insertions(+) diff --git a/frontend/src/features/export/index.test.ts b/frontend/src/features/export/index.test.ts index 47f8a6e8..9cd33f78 100644 --- a/frontend/src/features/export/index.test.ts +++ b/frontend/src/features/export/index.test.ts @@ -149,6 +149,15 @@ describe('Character asset publisher', () => { ]) }) + it('rejects a directional action when any real source direction is missing', () => { + expect(() => + exportFeature.createActionSequences( + [directionalAnimationFixture('generation-east', 'east', 'east')], + 'four-way', + ), + ).toThrow('完整动画方向 north 的生成结果不可发布') + }) + it('rejects a completed task from another project', async () => { const publisher = createRejectingPublisher() diff --git a/frontend/src/features/workflow-controller/controller.test.ts b/frontend/src/features/workflow-controller/controller.test.ts index 86527a12..98825534 100644 --- a/frontend/src/features/workflow-controller/controller.test.ts +++ b/frontend/src/features/workflow-controller/controller.test.ts @@ -1402,6 +1402,67 @@ describe('WorkflowController', () => { ]) }) + it('方向重试拒绝非生成节点、不可重试状态和缺失任务引用', async () => { + const { controller } = createController( + createRun([ + setupNode({ status: 'passed', phase: 'completed' }), + templateNode({ + status: 'passed', + phase: 'completed', + selectedImageUrl: 'east-template.png', + generations: [{ taskId: 'task-east', role: 'character_template' }], + }), + firstFrameNode({ status: 'failed', phase: 'generating' }), + generationMethodNode({ status: 'failed', phase: 'selecting' }), + ]), + 'four-way', + ) + + await expect( + controller.retryGenerationDirection('action-walk:action-generation-method', 'north', { + spriteWidth: 64, + spriteHeight: 64, + }), + ).rejects.toThrow('目标节点不是生成节点') + await expect( + controller.retryGenerationDirection('template-1', 'east', { + spriteWidth: 64, + spriteHeight: 64, + }), + ).rejects.toThrow('当前方向不能重新生成') + await expect( + controller.retryGenerationDirection('action-walk', 'north', { + spriteWidth: 64, + spriteHeight: 64, + }), + ).rejects.toThrow('方向 north 没有可替换的生成任务') + }) + + it('方向重试提交失败时恢复原节点及失败信息', async () => { + const original = templateNode({ + status: 'failed', + phase: 'generating', + generations: [{ taskId: 'task-north', role: 'character_template', direction: 'north' }], + error: 'north provider failed', + selectedImageUrl: 'east-template.png', + selectedImages: { east: 'east-template.png', north: 'north-template.png' }, + }) + const { controller, generation } = createController( + createRun([setupNode({ status: 'passed', phase: 'completed' }), original]), + 'four-way', + ) + vi.mocked(generation.apis.create).mockRejectedValueOnce(new Error('retry request failed')) + + await expect( + controller.retryGenerationDirection('template-1', 'north', { + spriteWidth: 64, + spriteHeight: 64, + }), + ).rejects.toThrow('retry request failed') + + expect(controller.getWorkflow().nodes[1]).toEqual(original) + }) + it('刷新后恢复其它方向订阅失败时仍保留新建的重试任务引用', async () => { const run = createRun([ setupNode({ status: 'passed', phase: 'completed' }), diff --git a/frontend/src/pages/quick-start/index.test.tsx b/frontend/src/pages/quick-start/index.test.tsx index 04a8edff..fce73b27 100644 --- a/frontend/src/pages/quick-start/index.test.tsx +++ b/frontend/src/pages/quick-start/index.test.tsx @@ -1362,6 +1362,23 @@ describe('QuickStartPage', () => { ) }) + it('方向重试失败时显示原始错误并恢复按钮', async () => { + const run = actionWorkflow({ firstStatus: 'failed', error: '北方向失败' }) + const service = serviceFor(run, { + getFailedGenerationDirections: vi.fn(async () => [ + { nodeId: 'action-first', direction: 'north' as const }, + ]), + retryGenerationDirection: vi.fn(async () => Promise.reject(new Error('north retry failed'))), + }) + renderAt('/quick-start/run-1', service) + + const retryButton = await screen.findByRole('button', { name: '重试北方向' }) + fireEvent.click(retryButton) + + expect((await screen.findByRole('alert')).textContent).toContain('north retry failed') + await waitFor(() => expect((retryButton as HTMLButtonElement).disabled).toBe(false)) + }) + it('saves a completed animation without navigating and exposes both explicit destinations', async () => { const run = actionWorkflow({ fullStatus: 'passed', reviewStatus: 'active' }) const approved = actionWorkflow({ fullStatus: 'passed', reviewStatus: 'passed' }) diff --git a/frontend/src/pages/quick-start/service.test.ts b/frontend/src/pages/quick-start/service.test.ts index 94906d3c..c0366561 100644 --- a/frontend/src/pages/quick-start/service.test.ts +++ b/frontend/src/pages/quick-start/service.test.ts @@ -300,6 +300,148 @@ describe('createQuickStartService', () => { await expect(service.open('missing')).rejects.toThrow('not found') }) + it('只列出各生成节点真正失败的方向', async () => { + const run: WorkflowRun = { + id: 'run-failed-directions', + projectId: 'project-1', + version: 1, + storageStatus: 'active', + nodes: [ + { + id: 'setup', + type: 'character-setup', + status: 'passed', + phase: 'completed', + dependsOnNodeIds: [], + generations: [], + error: null, + input: { prompt: '像素骑士', referenceMedia: [] }, + }, + { + id: 'template', + type: 'character-template', + status: 'failed', + phase: 'generating', + dependsOnNodeIds: ['setup'], + generations: [ + { taskId: 'template-east', role: 'character_template' }, + { taskId: 'template-north', role: 'character_template', direction: 'north' }, + ], + error: 'north failed', + selectedImageUrl: null, + }, + { + id: 'first-frame', + type: 'action-first-frame', + status: 'failed', + phase: 'generating', + dependsOnNodeIds: ['template'], + generations: [ + { taskId: 'first-east', role: 'first_frame' }, + { taskId: 'first-south', role: 'first_frame', direction: 'south' }, + ], + error: 'east failed', + input: { outfitId: 'outfit-1', name: '挥手', type: 'custom', prompt: null, fps: 12 }, + selectedFirstFrameUrl: null, + }, + { + id: 'method', + type: 'action-generation-method', + status: 'failed', + phase: 'selecting', + dependsOnNodeIds: ['first-frame'], + generations: [], + error: 'method failed', + method: null, + }, + { + id: 'full-frame', + type: 'action-full-frame', + status: 'failed', + phase: 'generating', + dependsOnNodeIds: ['method'], + generations: [ + { taskId: 'full-north', role: 'complete_animation', direction: 'north' }, + { taskId: 'full-south', role: 'complete_animation', direction: 'south' }, + ], + error: 'south failed', + }, + { + id: 'deleted-template', + type: 'character-template', + status: 'failed', + phase: 'generating', + dependsOnNodeIds: ['setup'], + generations: [{ taskId: 'deleted-east', role: 'character_template' }], + error: 'deleted failed', + selectedImageUrl: null, + deletedAt: '2026-08-20T00:00:00Z', + }, + ], + } + const outcomes = new Map< + string, + readonly [ + 'character_template' | 'first_frame' | 'complete_animation', + 'pending' | 'completed' | 'failed', + ] + >([ + ['template-east', ['character_template', 'pending']], + ['template-north', ['character_template', 'failed']], + ['first-east', ['first_frame', 'failed']], + ['first-south', ['first_frame', 'completed']], + ['full-north', ['complete_animation', 'completed']], + ['full-south', ['complete_animation', 'failed']], + ]) + const generationApis: GenerationApis = { + create: vi.fn(async (input) => ({ + id: 'retry-north', + projectId: input.projectId, + type: input.type, + status: 'pending' as const, + result: null, + error: null, + })) as GenerationApis['create'], + get: vi.fn(async (projectId, id) => { + const outcome = + outcomes.get(id) ?? + (id === 'retry-north' ? (['character_template', 'pending'] as const) : undefined) + if (!outcome) throw new Error(`unexpected generation: ${id}`) + return { + id, + projectId, + type: outcome[0], + status: outcome[1], + result: null, + error: outcome[1] === 'failed' ? `${id} failed` : null, + } + }) as GenerationApis['get'], + subscribe: vi.fn(() => () => undefined), + } + const service = createQuickStartService({ + workflowRunApis: createWorkflowRunApis([run]), + generationApis, + prepareProject: vi.fn(), + projectApis: projectReader(undefined, 'four-way'), + }) + + const session = await service.open(run.id) + + await expect(session.getFailedGenerationDirections()).resolves.toEqual([ + { nodeId: 'template', direction: 'north' }, + { nodeId: 'first-frame', direction: 'east' }, + { nodeId: 'full-frame', direction: 'south' }, + ]) + expect(generationApis.get).toHaveBeenCalledTimes(6) + + await session.retryGenerationDirection('template', 'north') + + expect(generationApis.create).toHaveBeenCalledTimes(1) + expect(generationApis.create).toHaveBeenCalledWith( + expect.objectContaining({ type: 'character_template', direction: 'north' }), + ) + }) + it('creates a readable bounded project name without a hash suffix', async () => { const create = vi.fn(async (input) => ({ id: 'project-1', @@ -675,6 +817,7 @@ describe('createQuickStartService', () => { { index: 7, imageUrl: 'frame-7.png', durationMs: 83 }, { index: 9, imageUrl: 'frame-9.png', durationMs: null }, ]) + await expect(session.getExportModel()).rejects.toThrow('挥手的帧序号必须从 0 连续排列') session.dispose() await session.resume() vi.mocked(characterApis.update).mockRejectedValueOnce(new Error('asset write failed')) diff --git a/frontend/src/pages/workflow-editor/index.test.tsx b/frontend/src/pages/workflow-editor/index.test.tsx index d3b9eb32..508c1ab6 100644 --- a/frontend/src/pages/workflow-editor/index.test.tsx +++ b/frontend/src/pages/workflow-editor/index.test.tsx @@ -967,6 +967,70 @@ describe('WorkflowEditorPage real runtime boundary', () => { ) }) + it('四向动作首帧可以单独重做一个真实源方向', async () => { + const workflow = reviewingActionWorkflow() + const firstFrame = workflow.nodes.find((node) => node.type === 'action-first-frame') + if (!firstFrame || firstFrame.type !== 'action-first-frame') throw new Error('missing frame') + Object.assign(firstFrame, { + status: 'active', + phase: 'selecting', + generations: (['east', 'north', 'south'] as const).map((direction) => ({ + taskId: `first-${direction}`, + role: 'first_frame' as const, + direction, + })), + selectedFirstFrameUrl: null, + }) + for (const node of workflow.nodes) { + if (node.type === 'action-generation-method') { + node.status = 'locked' + node.phase = 'selecting' + node.method = null + } else if (node.type === 'action-full-frame') { + node.status = 'locked' + node.phase = 'ready' + node.generations = [] + } else if (node.type === 'review') { + node.status = 'locked' + } + } + const project = { ...projectFixture(), directionalMovement: 'four-way' as const } + const session = createSession(workflow, { + project, + generationApis: generationApisFixture({ + get: vi.fn(async (_projectId: string, taskId: string) => { + const direction = taskId.replace('first-', '') as 'east' | 'north' | 'south' + return { + id: taskId, + projectId: '1', + type: 'first_frame' as const, + status: 'completed' as const, + result: { + type: 'first_frame' as const, + direction, + images: [{ url: `${direction}-1.png` }, { url: `${direction}-2.png` }], + }, + error: null, + } + }) as GenerationApis['get'], + }), + }) + const retry = vi.spyOn(session.controller, 'retryGenerationDirection').mockResolvedValue() + defaultSessionLoader.mockResolvedValue(session) + renderEditor('/workflow-editor/42') + + fireEvent.click(await screen.findByRole('button', { name: '重做北方向' })) + + await waitFor(() => + expect(retry).toHaveBeenCalledWith(firstFrame.id, 'north', { + spriteWidth: 64, + spriteHeight: 64, + referenceMedia: [], + }), + ) + expect(screen.getByRole('img', { name: '北动作首帧候选 1' })).toBeTruthy() + }) + it('该造型没有 3D 资产时禁用三渲二选项并给出原因', async () => { const workflow = selectingGenerationMethodWorkflow() const session = createSession(workflow, { character: characterFixture() }) From 11bf0fb6477fe07c3cc8f3aeb43d1551d488a7a8 Mon Sep 17 00:00:00 2001 From: xyh202131 <246811510+xyh202131@users.noreply.github.com> Date: Thu, 20 Aug 2026 13:39:26 +0800 Subject: [PATCH 07/15] test(workflow): satisfy directional patch coverage --- frontend/src/entities/character/index.test.ts | 5 + frontend/src/entities/generation/api.test.ts | 66 +++++++ .../src/entities/workflow-run/api.test.ts | 2 + frontend/src/features/export/index.test.ts | 12 ++ .../workflow-controller/controller.test.ts | 168 ++++++++++++++++++ .../src/pages/quick-start/service.test.ts | 84 +++++++++ .../src/pages/workflow-editor/runtime.test.ts | 70 +++++++- 7 files changed, 406 insertions(+), 1 deletion(-) diff --git a/frontend/src/entities/character/index.test.ts b/frontend/src/entities/character/index.test.ts index 4b1e71b6..0ccd38ab 100644 --- a/frontend/src/entities/character/index.test.ts +++ b/frontend/src/entities/character/index.test.ts @@ -294,6 +294,11 @@ describe('characterApis', () => { outfits: characterDto.character_data.outfits, }, }) + + await characterApis.update({ ...character, templates: undefined }) + await expect(request?.json()).resolves.toMatchObject({ + character_data: { templates: [] }, + }) }) it('defaults model3dUrl to null when the outfit has no 3D asset yet', async () => { diff --git a/frontend/src/entities/generation/api.test.ts b/frontend/src/entities/generation/api.test.ts index 9b5bcd3b..002c04da 100644 --- a/frontend/src/entities/generation/api.test.ts +++ b/frontend/src/entities/generation/api.test.ts @@ -446,6 +446,72 @@ describe('createGenerationApis', () => { }) }) + it('未提供预期时从图片和动画任务推断方向并拒绝非法值', async () => { + const request = vi + .fn() + .mockResolvedValueOnce( + success( + taskData({ + input_payload: { num_images: 2, direction: 'north' }, + result: { + type: 'character_image', + direction: 'north', + image_urls: ['north-1.png', 'north-2.png'], + }, + }), + ), + ) + .mockResolvedValueOnce( + success( + taskData({ + task_type: 'character_action', + input_payload: { num_frames: 32, action_type: 'walk' }, + result: { + type: 'character_action', + action_type: 'walk', + frames: actionFrames(32), + }, + }), + ), + ) + .mockResolvedValueOnce( + success( + taskData({ + task_type: 'character_action', + input_payload: { num_frames: 32, action_type: 'walk', direction: 'north' }, + result: { + type: 'character_action', + action_type: 'walk', + direction: 'north', + frames: actionFrames(32), + }, + }), + ), + ) + .mockResolvedValueOnce(success(taskData({ input_payload: null }))) + .mockResolvedValueOnce( + success(taskData({ input_payload: { num_images: 2, direction: 'up' } })), + ) + const apis = createGenerationApis({ + transport: { request, stream: vi.fn(() => vi.fn()) }, + }) + + await expect(apis.get('42', '91')).resolves.toMatchObject({ + type: 'character_template', + result: { type: 'character_template', direction: 'north' }, + }) + await expect(apis.get('42', '91')).resolves.toMatchObject({ + type: 'complete_animation', + result: { type: 'complete_animation' }, + }) + await expect(apis.get('42', '91')).resolves.toMatchObject({ + type: 'complete_animation', + result: { type: 'complete_animation', direction: 'north' }, + }) + await expect(apis.get('42', '91')).rejects.toThrow('生成任务缺少 input_payload') + await expect(apis.get('42', '91')).rejects.toThrow('生成任务 direction 无效') + }) + it('拒绝未知任务状态而不是默认为 pending', async () => { const request = vi.fn(async () => success(taskData({ status: 'queued' }))) const apis = createGenerationApis({ diff --git a/frontend/src/entities/workflow-run/api.test.ts b/frontend/src/entities/workflow-run/api.test.ts index b1258cc9..eae353f1 100644 --- a/frontend/src/entities/workflow-run/api.test.ts +++ b/frontend/src/entities/workflow-run/api.test.ts @@ -113,6 +113,7 @@ describe('workflowRunApis', () => { generations: [{ taskId: 'task-template', role: 'character_template' }], error: null, selectedImageUrl: 'https://img/knight.png', + selectedImages: { east: 'https://img/knight.png' }, }, { id: 'first-frame-1', @@ -124,6 +125,7 @@ describe('workflowRunApis', () => { error: null, input: { outfitId: 'outfit-1', name: 'walk', type: 'walk', prompt: null, fps: 12 }, selectedFirstFrameUrl: 'https://img/walk-first.png', + selectedFirstFrameUrls: { east: 'https://img/walk-first.png' }, }, { id: 'generation-method-1', diff --git a/frontend/src/features/export/index.test.ts b/frontend/src/features/export/index.test.ts index 9cd33f78..ce0abf3c 100644 --- a/frontend/src/features/export/index.test.ts +++ b/frontend/src/features/export/index.test.ts @@ -158,6 +158,18 @@ describe('Character asset publisher', () => { ).toThrow('完整动画方向 north 的生成结果不可发布') }) + it('拒绝未携带任何生成结果的发布请求', async () => { + const publisher = createRejectingPublisher() + + await expect( + publisher.publishReviewedAction({ + character: characterFixture(), + workflow: workflowFixture(), + reviewNodeId: 'action-walk:review', + }), + ).rejects.toThrow('Generation 与当前 WorkflowRun 不匹配') + }) + it('rejects a completed task from another project', async () => { const publisher = createRejectingPublisher() diff --git a/frontend/src/features/workflow-controller/controller.test.ts b/frontend/src/features/workflow-controller/controller.test.ts index 98825534..337ed38f 100644 --- a/frontend/src/features/workflow-controller/controller.test.ts +++ b/frontend/src/features/workflow-controller/controller.test.ts @@ -1009,6 +1009,76 @@ describe('WorkflowController', () => { expect(generation.apis.create).not.toHaveBeenCalled() }) + it('生成入口拒绝不属于目标阶段的节点', async () => { + const { controller, generation } = createController( + createRun([...completedCharacterNodes(), ...actionNodes()]), + ) + + await expect( + controller.generateFirstFrame('template-1', { spriteWidth: 64, spriteHeight: 64 }), + ).rejects.toThrow('目标节点不是动作首帧') + await expect( + controller.generateCompleteAnimation('action-walk', { + characterId: 'character-1', + referenceMedia: [], + }), + ).rejects.toThrow('目标节点不是完整动画') + expect(generation.apis.create).not.toHaveBeenCalled() + }) + + it('生成入口在提交前重新校验节点阶段和动作生成方式', async () => { + const selectingFirstFrame = createController( + createRun([ + ...completedCharacterNodes(), + firstFrameNode({ status: 'active', phase: 'selecting' }), + ]), + ) + await expect( + selectingFirstFrame.controller.generateFirstFrame('action-walk', { + spriteWidth: 64, + spriteHeight: 64, + }), + ).rejects.toThrow('动作首帧节点当前不能生成') + + const generatingFullFrame = createController( + createRun([ + ...completedCharacterNodes(), + firstFrameNode({ + status: 'passed', + phase: 'completed', + selectedFirstFrameUrl: 'first.png', + }), + generationMethodNode({ status: 'passed', phase: 'completed', method: 'video-cropping' }), + fullFrameNode({ status: 'active', phase: 'generating' }), + ]), + ) + await expect( + generatingFullFrame.controller.generateCompleteAnimation('action-walk:action-full-frame', { + characterId: 'character-1', + referenceMedia: [], + }), + ).rejects.toThrow('完整动画节点当前不能生成') + + const missingMethod = createController( + createRun([ + ...completedCharacterNodes(), + firstFrameNode({ + status: 'passed', + phase: 'completed', + selectedFirstFrameUrl: 'first.png', + }), + generationMethodNode({ status: 'passed', phase: 'completed', method: null }), + fullFrameNode({ status: 'active', phase: 'ready' }), + ]), + ) + await expect( + missingMethod.controller.generateCompleteAnimation('action-walk:action-full-frame', { + characterId: 'character-1', + referenceMedia: [], + }), + ).rejects.toThrow('尚未选择动作生成方式') + }) + it('拒绝确认镜像方向,并在服务端返回错误方向时终止节点', async () => { const { controller, generation } = createController(createRun(), 'four-way') @@ -2484,6 +2554,104 @@ describe('WorkflowController', () => { }) }) + it('角色母版节点拒绝候选数量不足的结果', async () => { + const run = createRun([ + setupNode({ status: 'passed', phase: 'completed' }), + templateNode({ + status: 'active', + phase: 'generating', + generations: [{ taskId: 'task-template', role: 'character_template' }], + }), + ]) + const { controller } = createController(run) + + await controller.applyGenerationResult({ + nodeId: 'template-1', + taskId: 'task-template', + generation: { + id: 'task-template', + projectId: '1', + type: 'character_template', + status: 'completed', + result: { type: 'character_template', images: [{ url: 'only-one.png' }] }, + error: null, + }, + }) + + expect(controller.getWorkflow().nodes[1]).toMatchObject({ + status: 'failed', + error: '角色候选图结果格式无效', + }) + }) + + it('动作首帧节点拒绝其它类型的生成结果', async () => { + const run = createRun([ + ...completedCharacterNodes(), + firstFrameNode({ + phase: 'generating', + generations: [{ taskId: 'task-first', role: 'first_frame' }], + }), + ]) + const { controller } = createController(run) + + await controller.applyGenerationResult({ + nodeId: 'action-walk', + taskId: 'task-first', + generation: { + id: 'task-first', + projectId: '1', + type: 'character_template', + status: 'completed', + result: { + type: 'character_template', + images: [{ url: 'wrong-1.png' }, { url: 'wrong-2.png' }], + }, + error: null, + }, + }) + + expect(controller.getWorkflow().nodes[2]).toMatchObject({ + status: 'failed', + error: '动作首帧结果格式无效', + }) + }) + + it('完整动画节点拒绝帧数不足的结果', async () => { + const run = createRun([ + ...completedCharacterNodes(), + firstFrameNode({ status: 'passed', phase: 'completed', selectedFirstFrameUrl: 'first.png' }), + generationMethodNode({ status: 'passed', phase: 'completed', method: 'video-cropping' }), + fullFrameNode({ + status: 'active', + phase: 'generating', + generations: [{ taskId: 'task-animation', role: 'complete_animation' }], + }), + reviewNode(), + ]) + const { controller } = createController(run) + + await controller.applyGenerationResult({ + nodeId: 'action-walk:action-full-frame', + taskId: 'task-animation', + generation: { + id: 'task-animation', + projectId: '1', + type: 'complete_animation', + status: 'completed', + result: { + type: 'complete_animation', + frames: [{ index: 0, url: 'frame.png', durationMs: 80 }], + }, + error: null, + }, + }) + + expect(controller.getWorkflow().nodes[4]).toMatchObject({ + status: 'failed', + error: '完整动画应为 32 帧,实际为 1 帧', + }) + }) + it('一个并行 Action 失败不会阻止另一个 Action 接收生成结果', async () => { const run = createRun([ ...completedCharacterNodes(), diff --git a/frontend/src/pages/quick-start/service.test.ts b/frontend/src/pages/quick-start/service.test.ts index c0366561..0b511947 100644 --- a/frontend/src/pages/quick-start/service.test.ts +++ b/frontend/src/pages/quick-start/service.test.ts @@ -442,6 +442,90 @@ describe('createQuickStartService', () => { ) }) + it('没有动作首帧时拒绝确认候选', async () => { + const run: WorkflowRun = { + id: 'run-without-first-frame', + projectId: 'project-1', + version: 1, + storageStatus: 'active', + nodes: setupNodes(), + } + const service = createQuickStartService({ + workflowRunApis: createWorkflowRunApis([run]), + generationApis: pendingGenerationApis(), + prepareProject: vi.fn(), + projectApis: projectReader(), + }) + const session = await service.open(run.id) + + await expect(session.confirmFirstFrame('first.png')).rejects.toThrow( + '当前运行没有可确认的动作首帧', + ) + }) + + it('继续上传母版时拒绝没有可用造型的已有角色', async () => { + const run: WorkflowRun = { + id: 'run-upload-without-outfit', + projectId: 'project-1', + version: 1, + storageStatus: 'active', + nodes: setupNodes(), + } + const template = run.nodes[1] + if (!template || template.type !== 'character-template') throw new Error('missing template') + template.status = 'active' + template.phase = 'selecting' + const character = characterFixture({ workflowRunId: run.id, outfits: [] }) + const service = createQuickStartService({ + workflowRunApis: createWorkflowRunApis([run]), + generationApis: pendingGenerationApis(), + characterApis: mutableCharacterApis( + () => character, + () => undefined, + ), + mediaApis: { + upload: vi.fn(async () => 'replacement.png' as MediaReference), + }, + prepareProject: vi.fn(), + projectApis: projectReader(), + }) + const session = await service.open(run.id) + + await expect( + session.continueWithUploadedTemplate(new File(['pixels'], 'replacement.png'), ''), + ).rejects.toThrow('角色母版缺少可用造型') + }) + + it('重复确认母版时拒绝没有可用造型的已有角色', async () => { + const run: WorkflowRun = { + id: 'run-confirm-without-outfit', + projectId: 'project-1', + version: 1, + storageStatus: 'active', + nodes: setupNodes(), + } + const template = run.nodes[1] + if (!template || template.type !== 'character-template') throw new Error('missing template') + template.status = 'active' + template.phase = 'selecting' + const character = characterFixture({ workflowRunId: run.id, outfits: [] }) + const service = createQuickStartService({ + workflowRunApis: createWorkflowRunApis([run]), + generationApis: pendingGenerationApis(), + characterApis: mutableCharacterApis( + () => character, + () => undefined, + ), + prepareProject: vi.fn(), + projectApis: projectReader(), + }) + const session = await service.open(run.id) + + await expect(session.confirmCandidate('template.png', '')).rejects.toThrow( + '角色母版缺少可用造型', + ) + }) + it('creates a readable bounded project name without a hash suffix', async () => { const create = vi.fn(async (input) => ({ id: 'project-1', diff --git a/frontend/src/pages/workflow-editor/runtime.test.ts b/frontend/src/pages/workflow-editor/runtime.test.ts index 18fa8bf0..d1a297c0 100644 --- a/frontend/src/pages/workflow-editor/runtime.test.ts +++ b/frontend/src/pages/workflow-editor/runtime.test.ts @@ -258,6 +258,29 @@ describe('createRealWorkflowEditorSession', () => { ).toMatchObject({ status: 'passed', phase: 'completed' }) }) + it('四向流程先确认北向母版时用该图创建默认造型', async () => { + const { session, update } = await createCharacterTemplateSession({ + directionalMovement: 'four-way', + }) + + await session.confirmCharacterTemplate( + 'template', + 'https://assets.windup.test/north-master.png', + 'north', + ) + + expect(update).toHaveBeenCalledWith( + expect.objectContaining({ + referenceImageUrl: 'https://assets.windup.test/north-master.png', + outfits: [ + expect.objectContaining({ + previewUrl: 'https://assets.windup.test/north-master.png', + }), + ], + }), + ) + }) + it('拒绝用空图片确认身份母版', async () => { const { session, create } = await createCharacterTemplateSession() @@ -617,6 +640,45 @@ describe('createRealWorkflowEditorSession', () => { ) }) + it('拒绝发布没有生成任务引用的完整动画', async () => { + const workflow = reviewingWorkflowFixture() + const fullFrame = workflow.nodes.find((node) => node.type === 'action-full-frame')! + fullFrame.generations = [] + const session = await createRealWorkflowEditorSession('42', { + workflowRunApis: { + create: vi.fn(), + get: vi.fn().mockResolvedValue(workflow), + update: vi.fn(), + remove: vi.fn(), + }, + generationApis: { + create: vi.fn() as GenerationApis['create'], + get: vi.fn(), + subscribe: vi.fn(() => () => undefined), + }, + mediaApis: { upload: vi.fn() }, + projectApis: { get: vi.fn().mockResolvedValue(projectFixture()) }, + characterApis: { + listByProject: vi.fn().mockResolvedValue({ + items: [characterWithOutfitFixture()], + total: 1, + page: 1, + pageSize: 100, + }), + create: vi.fn(), + get: vi.fn(), + update: vi.fn(), + remove: vi.fn(), + }, + render3d: stubRender3DApis(), + onAsyncError: vi.fn(), + }) + + await expect(session.publishReviewedAction('action-walk:review')).rejects.toThrow( + '完整动画生成结果不存在', + ) + }) + it('审核 Run 已落库但响应丢失时保留已发布的动作资产', async () => { let savedWorkflow = reviewingWorkflowFixture() let savedCharacter = characterWithOutfitFixture() @@ -957,6 +1019,7 @@ async function createCharacterTemplateSession( characters?: Character[] mediaApis?: Pick workflowRunUpdate?: WorkflowRunApis['update'] + directionalMovement?: Project['directionalMovement'] } = {}, ) { const workflow = options.workflow ?? selectingCharacterTemplateWorkflowFixture() @@ -978,7 +1041,12 @@ async function createCharacterTemplateSession( get: vi.fn(), subscribe: vi.fn(() => () => undefined), }, - projectApis: { get: vi.fn().mockResolvedValue(projectFixture()) }, + projectApis: { + get: vi.fn().mockResolvedValue({ + ...projectFixture(), + directionalMovement: options.directionalMovement ?? 'single', + }), + }, characterApis: { listByProject: vi.fn().mockResolvedValue({ items: characters, From 70d3c971e21cad4d42f7ab45398e5b673d9a35ac Mon Sep 17 00:00:00 2001 From: xyh202131 <246811510+xyh202131@users.noreply.github.com> Date: Thu, 20 Aug 2026 14:08:20 +0800 Subject: [PATCH 08/15] test(workflow): raise directional patch coverage --- frontend/src/entities/generation/api.test.ts | 33 ++ .../workflow-controller/controller.test.ts | 429 ++++++++++++++++++ frontend/src/pages/quick-start/index.test.tsx | 27 ++ .../src/pages/quick-start/service.test.ts | 207 ++++++++- 4 files changed, 694 insertions(+), 2 deletions(-) diff --git a/frontend/src/entities/generation/api.test.ts b/frontend/src/entities/generation/api.test.ts index 002c04da..b0127838 100644 --- a/frontend/src/entities/generation/api.test.ts +++ b/frontend/src/entities/generation/api.test.ts @@ -130,6 +130,39 @@ describe('createGenerationApis', () => { }) }) + it('未指定方向时用 east 创建兼容的角色母版任务', async () => { + const request = vi.fn(async (_url: string, _init?: RequestInit) => + success( + taskData({ + input_payload: { num_images: 2, direction: 'east' }, + result: { + type: 'character_image', + direction: 'east', + image_urls: ['east-1.png', 'east-2.png'], + }, + }), + ), + ) + const apis = createGenerationApis({ + transport: { request, stream: vi.fn(() => vi.fn()) }, + }) + + const generation = await apis.create({ + type: 'character_template', + projectId: '42', + referenceMedia: [], + prompt: 'pixel hero', + spriteWidth: 64, + spriteHeight: 64, + }) + + expect(JSON.parse(String(request.mock.calls[0]?.[1]?.body))).toMatchObject({ direction: 'east' }) + expect(generation.result).toEqual({ + type: 'character_template', + images: [{ url: 'east-1.png' }, { url: 'east-2.png' }], + }) + }) + it('根据角色母版和动作提示词生成两张动作首帧候选', async () => { const request = vi.fn(async (_url: string, _init?: RequestInit) => success( diff --git a/frontend/src/features/workflow-controller/controller.test.ts b/frontend/src/features/workflow-controller/controller.test.ts index 337ed38f..7134d994 100644 --- a/frontend/src/features/workflow-controller/controller.test.ts +++ b/frontend/src/features/workflow-controller/controller.test.ts @@ -974,6 +974,86 @@ describe('WorkflowController', () => { }) }) + it('非东向确认优先沿用方向选择表中的东向兼容值', async () => { + const template = createController( + createRun([ + setupNode({ status: 'passed', phase: 'completed' }), + templateNode({ + status: 'active', + phase: 'selecting', + selectedImageUrl: null, + selectedImages: { east: 'east-template.png' }, + }), + ]), + 'four-way', + ) + await template.controller.confirmCharacterTemplate( + 'template-1', + 'north-template.png', + 'character-1', + 'north', + ) + expect(template.controller.getWorkflow().nodes[1]).toMatchObject({ + selectedImageUrl: 'east-template.png', + }) + + const firstFrame = createController( + createRun([ + ...completedCharacterNodes(), + firstFrameNode({ + status: 'active', + phase: 'selecting', + selectedFirstFrameUrl: null, + selectedFirstFrameUrls: { east: 'east-frame.png' }, + }), + ]), + 'four-way', + ) + await firstFrame.controller.confirmFirstFrame('action-walk', 'north-frame.png', 'north') + expect(firstFrame.controller.getWorkflow().nodes[2]).toMatchObject({ + selectedFirstFrameUrl: 'east-frame.png', + }) + }) + + it('非东向确认在尚无东向选择时不伪造兼容值', async () => { + const template = createController( + createRun([ + setupNode({ status: 'passed', phase: 'completed' }), + templateNode({ + status: 'active', + phase: 'selecting', + selectedImageUrl: null, + selectedImages: undefined, + }), + ]), + 'four-way', + ) + await template.controller.confirmCharacterTemplate( + 'template-1', + 'north-template.png', + 'character-1', + 'north', + ) + expect(template.controller.getWorkflow().nodes[1]).toMatchObject({ selectedImageUrl: null }) + + const firstFrame = createController( + createRun([ + ...completedCharacterNodes(), + firstFrameNode({ + status: 'active', + phase: 'selecting', + selectedFirstFrameUrl: null, + selectedFirstFrameUrls: undefined, + }), + ]), + 'four-way', + ) + await firstFrame.controller.confirmFirstFrame('action-walk', 'north-frame.png', 'north') + expect(firstFrame.controller.getWorkflow().nodes[2]).toMatchObject({ + selectedFirstFrameUrl: null, + }) + }) + it('四向旧角色只有东向兼容字段时拒绝创建任何首帧任务', async () => { const run = createRun([...completedCharacterNodes(), ...actionNodes()]) const { controller, generation } = createController(run, 'four-way') @@ -1472,6 +1552,309 @@ describe('WorkflowController', () => { ]) }) + it('重试东向时只清空对应的兼容选择字段', async () => { + const templateRetry = createController( + createRun([ + setupNode({ status: 'passed', phase: 'completed' }), + templateNode({ + status: 'failed', + phase: 'generating', + generations: [{ taskId: 'template-east', role: 'character_template' }], + selectedImageUrl: 'east-template.png', + selectedImages: { east: 'east-template.png', north: 'north-template.png' }, + error: 'east failed', + }), + ]), + ) + + await templateRetry.controller.retryGenerationDirection('template-1', 'east', { + spriteWidth: 64, + spriteHeight: 64, + }) + const retriedTemplate = templateRetry.controller.getWorkflow().nodes[1] + expect(retriedTemplate).toMatchObject({ + selectedImageUrl: null, + }) + if (!retriedTemplate || retriedTemplate.type !== 'character-template') { + throw new Error('missing template') + } + expect(retriedTemplate.selectedImages).toEqual({ north: 'north-template.png' }) + + const firstFrameRetry = createController( + createRun([ + ...completedCharacterNodes(), + firstFrameNode({ + status: 'failed', + phase: 'generating', + generations: [{ taskId: 'first-east', role: 'first_frame' }], + selectedFirstFrameUrl: 'east-frame.png', + selectedFirstFrameUrls: { east: 'east-frame.png', north: 'north-frame.png' }, + error: 'east failed', + }), + ]), + ) + + await firstFrameRetry.controller.retryGenerationDirection('action-walk', 'east', { + spriteWidth: 64, + spriteHeight: 64, + }) + const retriedFirstFrame = firstFrameRetry.controller.getWorkflow().nodes[2] + expect(retriedFirstFrame).toMatchObject({ + selectedFirstFrameUrl: null, + }) + if (!retriedFirstFrame || retriedFirstFrame.type !== 'action-first-frame') { + throw new Error('missing first frame') + } + expect(retriedFirstFrame.selectedFirstFrameUrls).toEqual({ north: 'north-frame.png' }) + }) + + it('方向重试在创建任务前校验同方向依赖', async () => { + const missingTemplate = createController( + createRun([ + setupNode({ status: 'passed', phase: 'completed' }), + templateNode({ + status: 'passed', + phase: 'completed', + selectedImageUrl: 'east-template.png', + selectedImages: { east: 'east-template.png', south: 'south-template.png' }, + }), + firstFrameNode({ + status: 'failed', + phase: 'generating', + generations: [{ taskId: 'first-north', role: 'first_frame', direction: 'north' }], + error: 'north failed', + }), + ]), + 'four-way', + ) + await expect( + missingTemplate.controller.retryGenerationDirection('action-walk', 'north', { + spriteWidth: 64, + spriteHeight: 64, + }), + ).rejects.toThrow('角色母版尚未确认方向 north') + expect(missingTemplate.generation.apis.create).not.toHaveBeenCalled() + + const fullFrameNodes = ( + setup = setupNode({ + status: 'passed', + phase: 'completed', + input: { prompt: '像素骑士', referenceMedia: [], characterId: 'character-1' }, + }), + method: 'video-cropping' | null = 'video-cropping', + selectedFirstFrameUrls: Record = { + east: 'east-frame.png', + north: 'north-frame.png', + south: 'south-frame.png', + }, + ) => [ + setup, + templateNode({ status: 'passed', phase: 'completed', selectedImageUrl: 'east-template.png' }), + firstFrameNode({ + status: 'passed', + phase: 'completed', + selectedFirstFrameUrl: 'east-frame.png', + selectedFirstFrameUrls, + }), + generationMethodNode({ status: 'passed', phase: 'completed', method }), + fullFrameNode({ + status: 'failed', + phase: 'generating', + generations: [{ taskId: 'full-north', role: 'complete_animation', direction: 'north' }], + error: 'north failed', + }), + reviewNode(), + ] + + const missingMethod = createController(createRun(fullFrameNodes(undefined, null)), 'four-way') + await expect( + missingMethod.controller.retryGenerationDirection( + 'action-walk:action-full-frame', + 'north', + { spriteWidth: 64, spriteHeight: 64 }, + ), + ).rejects.toThrow('尚未选择动作生成方式') + expect(missingMethod.generation.apis.create).not.toHaveBeenCalled() + + const missingCharacter = createController( + createRun( + fullFrameNodes(setupNode({ status: 'passed', phase: 'completed' })), + ), + 'four-way', + ) + await expect( + missingCharacter.controller.retryGenerationDirection( + 'action-walk:action-full-frame', + 'north', + { spriteWidth: 64, spriteHeight: 64 }, + ), + ).rejects.toThrow('characterId 不能为空') + expect(missingCharacter.generation.apis.create).not.toHaveBeenCalled() + + const missingFirstFrame = createController( + createRun(fullFrameNodes(undefined, 'video-cropping', { east: 'east-frame.png' })), + 'four-way', + ) + await expect( + missingFirstFrame.controller.retryGenerationDirection( + 'action-walk:action-full-frame', + 'north', + { spriteWidth: 64, spriteHeight: 64 }, + ), + ).rejects.toThrow('动作首帧尚未确认方向 north') + expect(missingFirstFrame.generation.apis.create).not.toHaveBeenCalled() + }) + + it('完整动画方向重试未传引用媒体时显式使用空数组', async () => { + const run = createRun([ + setupNode({ + status: 'passed', + phase: 'completed', + input: { prompt: '像素骑士', referenceMedia: [], characterId: 'character-1' }, + }), + templateNode({ status: 'passed', phase: 'completed', selectedImageUrl: 'east-template.png' }), + firstFrameNode({ + status: 'passed', + phase: 'completed', + selectedFirstFrameUrl: 'east-frame.png', + }), + generationMethodNode({ status: 'passed', phase: 'completed', method: 'video-cropping' }), + fullFrameNode({ + status: 'failed', + phase: 'generating', + generations: [{ taskId: 'full-east', role: 'complete_animation' }], + error: 'east failed', + }), + reviewNode(), + ]) + const { controller, generation } = createController(run) + + await controller.retryGenerationDirection('action-walk:action-full-frame', 'east', { + spriteWidth: 64, + spriteHeight: 64, + }) + + expect(generation.apis.create).toHaveBeenCalledWith( + expect.objectContaining({ type: 'complete_animation', referenceMedia: [], direction: 'east' }), + ) + }) + + it('失败任务没有可用错误文本时使用可诊断的默认错误', async () => { + const { controller, generation } = createController( + createRun([ + setupNode({ status: 'passed', phase: 'completed' }), + templateNode({ + status: 'active', + phase: 'generating', + generations: [ + { taskId: 'task-east', role: 'character_template' }, + { taskId: 'task-north', role: 'character_template', direction: 'north' }, + { taskId: 'task-south', role: 'character_template', direction: 'south' }, + ], + }), + ]), + 'four-way', + ) + generation.snapshots.set('task-north', { + id: 'task-north', + projectId: '1', + type: 'character_template', + status: 'failed', + result: null, + error: ' ', + }) + generation.snapshots.set('task-south', { + id: 'task-south', + projectId: '1', + type: 'character_template', + status: 'running', + result: null, + error: null, + }) + + await controller.applyGenerationResult({ + nodeId: 'template-1', + taskId: 'task-east', + generation: { + id: 'task-east', + projectId: '1', + type: 'character_template', + status: 'completed', + result: { + type: 'character_template', + direction: 'east', + images: [{ url: 'east-1.png' }, { url: 'east-2.png' }], + }, + error: null, + }, + }) + + expect(controller.getWorkflow().nodes[1]).toMatchObject({ + status: 'failed', + error: '方向生成任务失败', + }) + }) + + it('查询任务时可回退到唯一的非东向结果', async () => { + const run = createRun([ + setupNode({ status: 'passed', phase: 'completed' }), + templateNode({ + status: 'active', + phase: 'generating', + generations: [{ taskId: 'task-north', role: 'character_template', direction: 'north' }], + }), + ]) + const { controller, generation } = createController(run, 'four-way') + generation.snapshots.set('task-north', { + id: 'task-north', + projectId: '1', + type: 'character_template', + status: 'completed', + result: { + type: 'character_template', + direction: 'north', + images: [{ url: 'north-1.png' }, { url: 'north-2.png' }], + }, + error: null, + }) + + await expect(controller.getGeneration('template-1', 'character_template')).resolves.toMatchObject({ + id: 'task-north', + result: { direction: 'north' }, + }) + }) + + it('损坏运行把生成引用挂到非生成节点时安全忽略结果', async () => { + const setup = setupNode({ + status: 'active', + phase: 'configuring', + generations: [{ taskId: 'orphan-task', role: 'character_template' }], + }) + const { controller } = createController(createRun([setup])) + const before = controller.getWorkflow() + + await controller.applyGenerationResult({ + nodeId: setup.id, + taskId: 'orphan-task', + generation: { + id: 'orphan-task', + projectId: '1', + type: 'character_template', + status: 'completed', + result: { + type: 'character_template', + images: [{ url: 'one.png' }, { url: 'two.png' }], + }, + error: null, + }, + }) + + expect(controller.getWorkflow()).toEqual(before) + await expect(controller.getGenerations(setup.id, 'character_template')).rejects.toThrow( + '不是生成节点', + ) + }) + it('方向重试拒绝非生成节点、不可重试状态和缺失任务引用', async () => { const { controller } = createController( createRun([ @@ -1671,6 +2054,52 @@ describe('WorkflowController', () => { expect(generation.apis.create).not.toHaveBeenCalled() }) + it('四向微调在重启节点前拒绝缺失的同方向参考图', async () => { + const templateRun = createRun([ + setupNode({ status: 'passed', phase: 'completed' }), + templateNode({ + status: 'passed', + phase: 'completed', + selectedImageUrl: 'east-template.png', + selectedImages: { east: 'east-template.png', south: 'south-template.png' }, + }), + ]) + const template = createController(templateRun, 'four-way') + const templateBefore = template.controller.getWorkflow() + await expect( + template.controller.regenerateCharacterTemplate('template-1', { + spriteWidth: 64, + spriteHeight: 64, + mode: 'refine', + adjustmentPrompt: '加强阴影', + }), + ).rejects.toThrow('角色母版尚未确认方向 north') + expect(template.generation.apis.create).not.toHaveBeenCalled() + expect(template.controller.getWorkflow()).toEqual(templateBefore) + + const firstFrameRun = createRun([ + ...completedCharacterNodes(), + firstFrameNode({ + status: 'passed', + phase: 'completed', + selectedFirstFrameUrl: 'east-frame.png', + selectedFirstFrameUrls: { east: 'east-frame.png', south: 'south-frame.png' }, + }), + ]) + const firstFrame = createController(firstFrameRun, 'four-way') + const firstFrameBefore = firstFrame.controller.getWorkflow() + await expect( + firstFrame.controller.regenerateFirstFrame('action-walk', { + spriteWidth: 64, + spriteHeight: 64, + mode: 'refine', + adjustmentPrompt: '调整姿势', + }), + ).rejects.toThrow('动作首帧尚未确认方向 north') + expect(firstFrame.generation.apis.create).not.toHaveBeenCalled() + expect(firstFrame.controller.getWorkflow()).toEqual(firstFrameBefore) + }) + it('角色母版重新生成提交失败后还原用户已确认的图片', async () => { const previousImage = 'https://img/knight.png' const { controller, generation } = createController(createRun(completedCharacterNodes())) diff --git a/frontend/src/pages/quick-start/index.test.tsx b/frontend/src/pages/quick-start/index.test.tsx index fce73b27..cdaa7faa 100644 --- a/frontend/src/pages/quick-start/index.test.tsx +++ b/frontend/src/pages/quick-start/index.test.tsx @@ -1362,6 +1362,33 @@ describe('QuickStartPage', () => { ) }) + it('角色母版失败时也提供定向重试入口', async () => { + const run = workflow( + setupAndTemplate({ + status: 'failed', + phase: 'generating', + error: '北向母版失败', + generations: [ + { taskId: 'template-east', role: 'character_template' }, + { taskId: 'template-north', role: 'character_template', direction: 'north' }, + ], + }), + ) + const service = serviceFor(run, { + getFailedGenerationDirections: vi.fn(async () => [ + { nodeId: 'character-template', direction: 'north' as const }, + ]), + retryGenerationDirection: vi.fn(async () => run), + }) + renderAt('/quick-start/run-1', service) + + fireEvent.click(await screen.findByRole('button', { name: '重试北方向' })) + + await waitFor(() => + expect(service.retryGenerationDirection).toHaveBeenCalledWith('character-template', 'north'), + ) + }) + it('方向重试失败时显示原始错误并恢复按钮', async () => { const run = actionWorkflow({ firstStatus: 'failed', error: '北方向失败' }) const service = serviceFor(run, { diff --git a/frontend/src/pages/quick-start/service.test.ts b/frontend/src/pages/quick-start/service.test.ts index 0b511947..5cdf64d1 100644 --- a/frontend/src/pages/quick-start/service.test.ts +++ b/frontend/src/pages/quick-start/service.test.ts @@ -463,6 +463,105 @@ describe('createQuickStartService', () => { ) }) + it.each([ + ['north', '缺少north方向角色候选图'], + ['south', '缺少south方向角色候选图'], + ] as const)('四向母版确认拒绝缺失的 %s 候选', async (missing, message) => { + const run: WorkflowRun = { + id: `run-missing-${missing}-template`, + projectId: 'project-1', + version: 1, + storageStatus: 'active', + nodes: setupNodes(null, null), + } + const template = run.nodes[1] + if (!template || template.type !== 'character-template') throw new Error('missing template') + template.generations = (['east', 'north', 'south'] as const).map((direction) => ({ + taskId: `template-${direction}`, + role: 'character_template' as const, + ...(direction === 'east' ? {} : { direction }), + })) + const generationApis: GenerationApis = { + create: vi.fn(), + get: vi.fn(async (projectId, id) => { + const direction = id.replace('template-', '') as 'east' | 'north' | 'south' + return { + id, + projectId, + type: 'character_template' as const, + status: 'completed' as const, + result: { + type: 'character_template' as const, + direction, + images: + direction === missing + ? [] + : [{ url: `${direction}-1.png` }, { url: `${direction}-2.png` }], + }, + error: null, + } + }), + subscribe: vi.fn(() => () => undefined), + } + let character = characterFixture({ workflowRunId: run.id }) + const service = createQuickStartService({ + workflowRunApis: createWorkflowRunApis([run]), + generationApis, + characterApis: mutableCharacterApis( + () => character, + (value) => (character = value), + ), + prepareProject: vi.fn(), + projectApis: projectReader(undefined, 'four-way'), + }) + const session = await service.open(run.id) + + await expect(session.confirmCandidate('east-1.png', '')).rejects.toThrow(message) + }) + + it('四向首帧确认拒绝缺失的同方向候选', async () => { + const run = actionRun(true) + const firstFrame = run.nodes.find((node) => node.type === 'action-first-frame')! + firstFrame.generations = (['east', 'north', 'south'] as const).map((direction) => ({ + taskId: `first-${direction}`, + role: 'first_frame' as const, + ...(direction === 'east' ? {} : { direction }), + })) + const generationApis: GenerationApis = { + create: vi.fn(), + get: vi.fn(async (projectId, id) => { + const direction = id.replace('first-', '') as 'east' | 'north' | 'south' + return { + id, + projectId, + type: 'first_frame' as const, + status: 'completed' as const, + result: { + type: 'first_frame' as const, + direction, + images: + direction === 'north' + ? [] + : [{ url: `${direction}-1.png` }, { url: `${direction}-2.png` }], + }, + error: null, + } + }), + subscribe: vi.fn(() => () => undefined), + } + const service = createQuickStartService({ + workflowRunApis: createWorkflowRunApis([run]), + generationApis, + prepareProject: vi.fn(), + projectApis: projectReader(undefined, 'four-way'), + }) + const session = await service.open(run.id) + + await expect(session.confirmFirstFrame('east-1.png')).rejects.toThrow( + '缺少north方向动作首帧候选图', + ) + }) + it('继续上传母版时拒绝没有可用造型的已有角色', async () => { const run: WorkflowRun = { id: 'run-upload-without-outfit', @@ -475,7 +574,24 @@ describe('createQuickStartService', () => { if (!template || template.type !== 'character-template') throw new Error('missing template') template.status = 'active' template.phase = 'selecting' - const character = characterFixture({ workflowRunId: run.id, outfits: [] }) + template.selectedImageUrl = 'template.png' + const setup = run.nodes[0] + if (!setup || setup.type !== 'character-setup') throw new Error('missing setup') + setup.input.characterId = 'character-1' + const character = characterFixture({ + workflowRunId: run.id, + outfits: [ + { + id: 'unrelated-outfit', + characterId: 'character-1', + name: '其它造型', + description: null, + previewUrl: 'other.png', + model3dUrl: null, + actions: [], + }, + ], + }) const service = createQuickStartService({ workflowRunApis: createWorkflowRunApis([run]), generationApis: pendingGenerationApis(), @@ -508,7 +624,24 @@ describe('createQuickStartService', () => { if (!template || template.type !== 'character-template') throw new Error('missing template') template.status = 'active' template.phase = 'selecting' - const character = characterFixture({ workflowRunId: run.id, outfits: [] }) + template.selectedImageUrl = 'template.png' + const setup = run.nodes[0] + if (!setup || setup.type !== 'character-setup') throw new Error('missing setup') + setup.input.characterId = 'character-1' + const character = characterFixture({ + workflowRunId: run.id, + outfits: [ + { + id: 'unrelated-outfit', + characterId: 'character-1', + name: '其它造型', + description: null, + previewUrl: 'other.png', + model3dUrl: null, + actions: [], + }, + ], + }) const service = createQuickStartService({ workflowRunApis: createWorkflowRunApis([run]), generationApis: pendingGenerationApis(), @@ -1985,6 +2118,76 @@ describe('createQuickStartService', () => { noOutfit.startAction({ characterId: 'character', outfitId: 'missing' }, 'walk'), ).rejects.toThrow('当前造型还没有可用的角色母版,请先完成定妆再生成动作') + const outfitWithoutTemplate = characterFixture({ + id: 'character-with-empty-outfit', + workflowRunId: 'run', + referenceImageUrl: null, + templates: [], + outfits: [ + { + id: 'empty-outfit', + characterId: 'character-with-empty-outfit', + name: '空造型', + description: null, + previewUrl: null, + model3dUrl: null, + actions: [], + }, + ], + }) + const noTemplate = createQuickStartService({ + workflowRunApis: createWorkflowRunApis(), + generationApis, + prepareProject: vi.fn(), + projectApis: projectReader(), + characterApis: { + get: vi.fn(async () => outfitWithoutTemplate), + } as unknown as CharacterApis, + }) + await expect( + noTemplate.startAction( + { characterId: outfitWithoutTemplate.id, outfitId: 'empty-outfit' }, + 'walk', + ), + ).rejects.toThrow('当前造型还没有可用的角色母版,请先完成定妆再生成动作') + + const characterReferenceOnly = characterFixture({ + id: 'character-reference-only', + referenceImageUrl: 'character-reference.png', + templates: [], + outfits: [ + { + id: 'reference-outfit', + characterId: 'character-reference-only', + name: '参考图造型', + description: null, + previewUrl: null, + model3dUrl: null, + actions: [], + }, + ], + }) + const referenceGenerations = pendingGenerationApis() + const referenceFallback = createQuickStartService({ + workflowRunApis: createWorkflowRunApis(), + generationApis: referenceGenerations, + prepareProject: vi.fn(), + projectApis: projectReader(), + characterApis: { + get: vi.fn(async () => characterReferenceOnly), + } as unknown as CharacterApis, + }) + await referenceFallback.startAction( + { characterId: characterReferenceOnly.id, outfitId: 'reference-outfit' }, + 'walk', + ) + expect(referenceGenerations.create).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'first_frame', + referenceMedia: ['character-reference.png'], + }), + ) + const staticRun: WorkflowRun = { id: 'run-static', projectId: 'project-1', From 9c19971058d569c25451a795eb56dc221613ff75 Mon Sep 17 00:00:00 2001 From: xyh202131 <246811510+xyh202131@users.noreply.github.com> Date: Thu, 20 Aug 2026 14:09:47 +0800 Subject: [PATCH 09/15] style(workflow): format coverage tests --- frontend/src/entities/generation/api.test.ts | 4 +++- .../workflow-controller/controller.test.ts | 23 +++++++++++-------- 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/frontend/src/entities/generation/api.test.ts b/frontend/src/entities/generation/api.test.ts index b0127838..da2767a4 100644 --- a/frontend/src/entities/generation/api.test.ts +++ b/frontend/src/entities/generation/api.test.ts @@ -156,7 +156,9 @@ describe('createGenerationApis', () => { spriteHeight: 64, }) - expect(JSON.parse(String(request.mock.calls[0]?.[1]?.body))).toMatchObject({ direction: 'east' }) + expect(JSON.parse(String(request.mock.calls[0]?.[1]?.body))).toMatchObject({ + direction: 'east', + }) expect(generation.result).toEqual({ type: 'character_template', images: [{ url: 'east-1.png' }, { url: 'east-2.png' }], diff --git a/frontend/src/features/workflow-controller/controller.test.ts b/frontend/src/features/workflow-controller/controller.test.ts index 7134d994..914bad37 100644 --- a/frontend/src/features/workflow-controller/controller.test.ts +++ b/frontend/src/features/workflow-controller/controller.test.ts @@ -1668,18 +1668,15 @@ describe('WorkflowController', () => { const missingMethod = createController(createRun(fullFrameNodes(undefined, null)), 'four-way') await expect( - missingMethod.controller.retryGenerationDirection( - 'action-walk:action-full-frame', - 'north', - { spriteWidth: 64, spriteHeight: 64 }, - ), + missingMethod.controller.retryGenerationDirection('action-walk:action-full-frame', 'north', { + spriteWidth: 64, + spriteHeight: 64, + }), ).rejects.toThrow('尚未选择动作生成方式') expect(missingMethod.generation.apis.create).not.toHaveBeenCalled() const missingCharacter = createController( - createRun( - fullFrameNodes(setupNode({ status: 'passed', phase: 'completed' })), - ), + createRun(fullFrameNodes(setupNode({ status: 'passed', phase: 'completed' }))), 'four-way', ) await expect( @@ -1735,7 +1732,11 @@ describe('WorkflowController', () => { }) expect(generation.apis.create).toHaveBeenCalledWith( - expect.objectContaining({ type: 'complete_animation', referenceMedia: [], direction: 'east' }), + expect.objectContaining({ + type: 'complete_animation', + referenceMedia: [], + direction: 'east', + }), ) }) @@ -1818,7 +1819,9 @@ describe('WorkflowController', () => { error: null, }) - await expect(controller.getGeneration('template-1', 'character_template')).resolves.toMatchObject({ + await expect( + controller.getGeneration('template-1', 'character_template'), + ).resolves.toMatchObject({ id: 'task-north', result: { direction: 'north' }, }) From 1039d66277d757fcc3f5e1d7f53544c53774d72a Mon Sep 17 00:00:00 2001 From: xyh202131 <246811510+xyh202131@users.noreply.github.com> Date: Thu, 20 Aug 2026 14:16:53 +0800 Subject: [PATCH 10/15] test(projects): stabilize thumbnail fallback check --- frontend/src/pages/projects/index.test.tsx | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/frontend/src/pages/projects/index.test.tsx b/frontend/src/pages/projects/index.test.tsx index 3b5db130..b190e6d3 100644 --- a/frontend/src/pages/projects/index.test.tsx +++ b/frontend/src/pages/projects/index.test.tsx @@ -58,11 +58,14 @@ describe('ProjectsPage', () => { fireEvent.error(preview) - await waitFor(() => { - expect(preview.getAttribute('src')).toBe( - 'https://cdn.windup.test/media/outfit-preview/messenger.source.png', - ) - }) + await waitFor( + () => { + expect(preview.getAttribute('src')).toBe( + 'https://cdn.windup.test/media/outfit-preview/messenger.source.png', + ) + }, + { timeout: 5_000 }, + ) }) it('keeps pending project previews distinct from empty projects', async () => { From 720df53bb1ae60bc94d1a89748c645955b178244 Mon Sep 17 00:00:00 2001 From: xyh202131 <246811510+xyh202131@users.noreply.github.com> Date: Thu, 20 Aug 2026 15:30:12 +0800 Subject: [PATCH 11/15] fix(workflow): require directional candidate confirmation Expose and persist user-selected candidates for every direction, and preserve durable retry task references when recovery subscriptions fail. --- .../workflow-controller/controller.test.ts | 39 +++ .../workflow-controller/controller.ts | 6 +- frontend/src/pages/quick-start/index.test.tsx | 132 ++++++-- frontend/src/pages/quick-start/index.tsx | 283 ++++++++++++------ .../src/pages/quick-start/service.test.ts | 122 +++++--- frontend/src/pages/quick-start/service.ts | 125 ++++---- 6 files changed, 482 insertions(+), 225 deletions(-) diff --git a/frontend/src/features/workflow-controller/controller.test.ts b/frontend/src/features/workflow-controller/controller.test.ts index 914bad37..e1478de3 100644 --- a/frontend/src/features/workflow-controller/controller.test.ts +++ b/frontend/src/features/workflow-controller/controller.test.ts @@ -1994,6 +1994,45 @@ describe('WorkflowController', () => { ) }) + it('新方向任务引用落库后订阅失败时不回滚到旧失败任务', async () => { + const run = createRun([ + setupNode({ status: 'passed', phase: 'completed' }), + templateNode({ + status: 'failed', + phase: 'generating', + error: 'north provider failed', + generations: [ + { taskId: 'task-east', role: 'character_template' }, + { taskId: 'task-north', role: 'character_template', direction: 'north' }, + { taskId: 'task-south', role: 'character_template', direction: 'south' }, + ], + }), + ]) + const { controller, workflow, generation } = createController(run, 'four-way') + vi.mocked(generation.apis.subscribe).mockImplementationOnce(() => { + throw new Error('新任务订阅失败') + }) + + await expect( + controller.retryGenerationDirection('template-1', 'north', { + spriteWidth: 64, + spriteHeight: 64, + }), + ).rejects.toThrow('新任务订阅失败') + + expect(workflow.getSaved().nodes[1]).toMatchObject({ + status: 'active', + phase: 'generating', + error: null, + generations: [ + { taskId: 'task-east', role: 'character_template' }, + { taskId: 'task-south', role: 'character_template', direction: 'south' }, + { taskId: 'task-1', role: 'character_template', direction: 'north' }, + ], + }) + expect(generation.apis.create).toHaveBeenCalledTimes(1) + }) + it('角色母版微调由 Controller 读取上一版图片并组合临时描述', async () => { const previousImage = 'https://img/knight.png' const { controller, generation } = createController(createRun(completedCharacterNodes())) diff --git a/frontend/src/features/workflow-controller/controller.ts b/frontend/src/features/workflow-controller/controller.ts index f99177cc..5f59921e 100644 --- a/frontend/src/features/workflow-controller/controller.ts +++ b/frontend/src/features/workflow-controller/controller.ts @@ -996,7 +996,11 @@ export function createWorkflowController({ direction, ) } catch (cause) { - await persist((run) => replaceNode(run, originalNode)) + const currentNode = findNode(requireWorkflow(), nodeId) + const attachedRetry = currentNode.generations.some( + (item) => role === item.role && generationReferenceDirection(item) === direction, + ) + if (!attachedRetry) await persist((run) => replaceNode(run, originalNode)) throw cause } // 新任务已经持久化后不能再回滚,否则恢复订阅的瞬时失败会遗失付费任务引用。 diff --git a/frontend/src/pages/quick-start/index.test.tsx b/frontend/src/pages/quick-start/index.test.tsx index cdaa7faa..4930ca22 100644 --- a/frontend/src/pages/quick-start/index.test.tsx +++ b/frontend/src/pages/quick-start/index.test.tsx @@ -3,7 +3,7 @@ import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-libra import { MemoryRouter, Route, Routes, useLocation, useNavigate } from 'react-router' import { afterEach, describe, expect, it, vi } from 'vitest' -import type { QuickStartEntryService, QuickStartSession } from './service' +import type { QuickStartCandidate, QuickStartEntryService, QuickStartSession } from './service' import { WorkflowRunConflictError, type WorkflowRun } from '@/entities' import type { ExportPackageModel } from '@/features/export-package' import { QuickStartPage } from './index' @@ -140,6 +140,10 @@ function serviceFor(run: WorkflowRun | null, overrides: Partial return service } +function eastCandidates(...imageUrls: string[]): readonly QuickStartCandidate[] { + return imageUrls.map((imageUrl, index) => ({ direction: 'east', index, imageUrl })) +} + function deferred() { let resolve!: (value: T) => void let reject!: (reason?: unknown) => void @@ -209,6 +213,7 @@ function renderStateFixture( 'https://example.test/character-2.png', ] const firstFrames = candidateUrls.map((_, index) => ({ + direction: 'east' as const, index, imageUrl: `https://example.test/first-${index + 1}.png`, durationMs: 80, @@ -229,7 +234,9 @@ function renderStateFixture( const run = workflow(setupAndTemplate()) return renderAt( '/quick-start/run-1', - serviceFor(run, { getTemplateCandidates: vi.fn(async () => candidateUrls) }), + serviceFor(run, { + getTemplateCandidates: vi.fn(async () => eastCandidates(...candidateUrls)), + }), ) } if (state === 'first-generating') { @@ -744,11 +751,13 @@ describe('QuickStartPage', () => { const selectingRun = workflow(setupAndTemplate()) const nextRun = actionWorkflow({ firstStatus: 'active', firstPhase: 'generating' }) const service = serviceFor(selectingRun, { - getTemplateCandidates: vi.fn(async () => [ - 'https://example.test/character-1.png', - 'https://example.test/character-2.png', - 'https://example.test/character-3.png', - ]), + getTemplateCandidates: vi.fn(async () => + eastCandidates( + 'https://example.test/character-1.png', + 'https://example.test/character-2.png', + 'https://example.test/character-3.png', + ), + ), confirmCandidate: vi.fn(async () => nextRun), }) renderAt('/quick-start/run-1', service) @@ -777,11 +786,43 @@ describe('QuickStartPage', () => { expect(transcript).not.toContain('你选择了') expect(transcript).toContain('摆好动作姿态') expect(service.confirmCandidate).toHaveBeenCalledWith( - 'https://example.test/character-2.png', + { east: 'https://example.test/character-2.png' }, '转身挥动风灯', ) }) + it('四向角色候选全部选定后才提交逐方向选择', async () => { + const selectingRun = workflow(setupAndTemplate()) + const directionalCandidates = [ + { direction: 'east', index: 0, imageUrl: 'east-1.png' }, + { direction: 'east', index: 1, imageUrl: 'east-2.png' }, + { direction: 'north', index: 0, imageUrl: 'north-1.png' }, + { direction: 'north', index: 1, imageUrl: 'north-2.png' }, + { direction: 'south', index: 0, imageUrl: 'south-1.png' }, + { direction: 'south', index: 1, imageUrl: 'south-2.png' }, + ] satisfies readonly QuickStartCandidate[] + const service = serviceFor(selectingRun, { + getTemplateCandidates: vi.fn(async () => directionalCandidates), + }) + renderAt('/quick-start/run-1', service) + + const submit = await screen.findByRole('button', { name: '确认选择,继续下一步' }) + fireEvent.click(await screen.findByRole('button', { name: '选择东方向角色方案 2' })) + expect(submit.hasAttribute('disabled')).toBe(true) + fireEvent.click(screen.getByRole('button', { name: '选择北方向角色方案 1' })) + expect(submit.hasAttribute('disabled')).toBe(true) + fireEvent.click(screen.getByRole('button', { name: '选择南方向角色方案 2' })) + expect(submit.hasAttribute('disabled')).toBe(false) + fireEvent.click(submit) + + await waitFor(() => + expect(service.confirmCandidate).toHaveBeenCalledWith( + { east: 'east-2.png', north: 'north-1.png', south: 'south-2.png' }, + '', + ), + ) + }) + it('keeps the natural-language creation entry visible when no run is selected', () => { render( @@ -800,10 +841,10 @@ describe('QuickStartPage', () => { it('shows first-frame confirmation instead of stale character candidates after a template is confirmed', async () => { const run = actionWorkflow({ firstStatus: 'active', firstPhase: 'selecting' }) const service = serviceFor(run, { - getTemplateCandidates: vi.fn(async () => ['stale-template.png']), - getFirstFrameCandidates: vi.fn(async () => [ - { index: 0, imageUrl: 'first-frame.png', durationMs: null }, - ]), + getTemplateCandidates: vi.fn(async () => eastCandidates('stale-template.png')), + getFirstFrameCandidates: vi.fn(async () => + eastCandidates('first-frame.png', 'first-frame-2.png'), + ), }) const view = renderAt('/quick-start/run-1', service) @@ -968,7 +1009,9 @@ describe('QuickStartPage', () => { it('selects a character first, then submits its action through the conversation composer', async () => { const run = workflow(setupAndTemplate()) const service = serviceFor(run, { - getTemplateCandidates: vi.fn(async () => ['https://example.test/candidate.png']), + getTemplateCandidates: vi.fn(async () => + eastCandidates('https://example.test/candidate.png'), + ), confirmCandidate: vi.fn(async () => Promise.reject(new Error('候选确认失败'))), start: vi.fn(async () => Promise.reject(new Error('重新生成失败'))), }) @@ -985,7 +1028,7 @@ describe('QuickStartPage', () => { fireEvent.click(screen.getByRole('button', { name: '确认选择,继续下一步' })) await waitFor(() => expect(service.confirmCandidate).toHaveBeenCalledWith( - 'https://example.test/candidate.png', + { east: 'https://example.test/candidate.png' }, '挥手', ), ) @@ -996,7 +1039,9 @@ describe('QuickStartPage', () => { it('freezes the current conversation and offers a full reload after a version conflict', async () => { const run = workflow(setupAndTemplate()) const service = serviceFor(run, { - getTemplateCandidates: vi.fn(async () => ['https://example.test/candidate.png']), + getTemplateCandidates: vi.fn(async () => + eastCandidates('https://example.test/candidate.png'), + ), confirmCandidate: vi.fn(async () => { throw new WorkflowRunConflictError('执行记录版本冲突,请刷新后重试') }), @@ -1021,7 +1066,7 @@ describe('QuickStartPage', () => { const run = workflow(setupAndTemplate()) let reportError: ((error: Error) => void) | null = null let rejectRead: ((error: Error) => void) | null = null - const pendingRead = new Promise((_resolve, reject) => { + const pendingRead = new Promise((_resolve, reject) => { rejectRead = reject }) const service = serviceFor(run, { @@ -1113,7 +1158,7 @@ describe('QuickStartPage', () => { if (newSetup?.type !== 'character-setup') throw new Error('测试工作流缺少角色设定节点') newSetup.input.prompt = '当前新运行' const newRun = workflow(newNodes, 'run-new') - const oldRead = deferred() + const oldRead = deferred() let reportOldError: ((error: Error) => void) | null = null const oldSession = serviceFor(oldRun, { getTemplateCandidates: vi.fn(() => oldRead.promise), @@ -1232,7 +1277,9 @@ describe('QuickStartPage', () => { resolveRegeneration = resolve }) const oldSession = serviceFor(oldRun, { - getTemplateCandidates: vi.fn(async () => ['https://example.test/candidate.png']), + getTemplateCandidates: vi.fn(async () => + eastCandidates('https://example.test/candidate.png'), + ), }) const newSession = serviceFor(newRun) const entryService = serviceFor(null, { @@ -1266,7 +1313,9 @@ describe('QuickStartPage', () => { const newRun = workflow(newNodes, 'run-new') const regeneration = deferred() const oldSession = serviceFor(oldRun, { - getTemplateCandidates: vi.fn(async () => ['https://example.test/candidate.png']), + getTemplateCandidates: vi.fn(async () => + eastCandidates('https://example.test/candidate.png'), + ), }) const newSession = serviceFor(newRun) const entryService = serviceFor(null, { @@ -1294,7 +1343,9 @@ describe('QuickStartPage', () => { it('keeps the original regenerate and new-creation controls reachable', async () => { const run = workflow(setupAndTemplate()) const service = serviceFor(run, { - getTemplateCandidates: vi.fn(async () => ['https://example.test/candidate.png']), + getTemplateCandidates: vi.fn(async () => + eastCandidates('https://example.test/candidate.png'), + ), start: vi.fn(async () => Promise.reject(new Error('重新生成失败'))), }) renderAt('/quick-start/run-1', service) @@ -1310,9 +1361,7 @@ describe('QuickStartPage', () => { it('confirms a generated first frame before starting the full animation', async () => { const run = actionWorkflow({ firstStatus: 'active', firstPhase: 'selecting' }) const service = serviceFor(run, { - getFirstFrameCandidates: vi.fn(async () => [ - { index: 4, imageUrl: 'https://example.test/first.png', durationMs: 80 }, - ]), + getFirstFrameCandidates: vi.fn(async () => eastCandidates('https://example.test/first.png')), confirmFirstFrame: vi.fn(async () => Promise.reject(new Error('首帧确认失败'))), }) renderAt('/quick-start/run-1', service) @@ -1320,11 +1369,46 @@ describe('QuickStartPage', () => { expect(service.confirmFirstFrame).not.toHaveBeenCalled() fireEvent.click(screen.getByRole('button', { name: '确认首帧,生成完整动作' })) await waitFor(() => - expect(service.confirmFirstFrame).toHaveBeenCalledWith('https://example.test/first.png'), + expect(service.confirmFirstFrame).toHaveBeenCalledWith({ + east: 'https://example.test/first.png', + }), ) expect((await screen.findByRole('alert')).textContent).toContain('首帧确认失败') }) + it('四向动作首帧全部选定后才确认并生成完整动作', async () => { + const run = actionWorkflow({ firstStatus: 'active', firstPhase: 'selecting' }) + const service = serviceFor(run, { + getFirstFrameCandidates: vi.fn( + async () => + [ + { direction: 'east', index: 0, imageUrl: 'east-1.png' }, + { direction: 'east', index: 1, imageUrl: 'east-2.png' }, + { direction: 'north', index: 0, imageUrl: 'north-1.png' }, + { direction: 'north', index: 1, imageUrl: 'north-2.png' }, + { direction: 'south', index: 0, imageUrl: 'south-1.png' }, + { direction: 'south', index: 1, imageUrl: 'south-2.png' }, + ] satisfies readonly QuickStartCandidate[], + ), + }) + renderAt('/quick-start/run-1', service) + + fireEvent.click(await screen.findByRole('button', { name: '选择东方向动作首帧 1' })) + expect(screen.queryByRole('button', { name: '确认首帧,生成完整动作' })).toBeNull() + fireEvent.click(screen.getByRole('button', { name: '选择北方向动作首帧 2' })) + expect(screen.queryByRole('button', { name: '确认首帧,生成完整动作' })).toBeNull() + fireEvent.click(screen.getByRole('button', { name: '选择南方向动作首帧 1' })) + fireEvent.click(screen.getByRole('button', { name: '确认首帧,生成完整动作' })) + + await waitFor(() => + expect(service.confirmFirstFrame).toHaveBeenCalledWith({ + east: 'east-1.png', + north: 'north-2.png', + south: 'south-1.png', + }), + ) + }) + it('renders generating and failed states for both first-frame and full animation tasks', async () => { const states = [ [actionWorkflow({ firstStatus: 'active', firstPhase: 'generating' }), '动作首帧生成进度'], diff --git a/frontend/src/pages/quick-start/index.tsx b/frontend/src/pages/quick-start/index.tsx index ddbcf157..c29ab862 100644 --- a/frontend/src/pages/quick-start/index.tsx +++ b/frontend/src/pages/quick-start/index.tsx @@ -23,6 +23,8 @@ import { PixelMatrix } from '@/shared/ui' import { KineticCopyCycle, type KineticCopyMessage } from './kinetic-copy-cycle' import { quickStartService, + type QuickStartCandidate, + type QuickStartDirectionSelections, type QuickStartEntryService, type QuickStartFailedDirection, type QuickStartFrame, @@ -88,6 +90,33 @@ const DIRECTION_LABELS = { south_west: '西南', } as const +function groupCandidates(candidates: readonly (QuickStartCandidate | string)[]) { + const groups = new Map() + for (const candidate of candidates) { + const direction = typeof candidate === 'string' ? 'east' : (candidate.direction ?? 'east') + const group = groups.get(direction) ?? [] + group.push( + typeof candidate === 'string' + ? { direction, index: group.length, imageUrl: candidate } + : { ...candidate, direction }, + ) + groups.set(direction, group) + } + return [...groups].map(([direction, items]) => ({ direction, items })) +} + +function allDirectionsSelected( + candidates: readonly (QuickStartCandidate | string)[], + selections: QuickStartDirectionSelections, +): boolean { + const directions = new Set( + candidates.map((candidate) => + typeof candidate === 'string' ? 'east' : (candidate.direction ?? 'east'), + ), + ) + return directions.size > 0 && [...directions].every((direction) => Boolean(selections[direction])) +} + const ROLE_DEFAULT_MESSAGE: readonly KineticCopyMessage[] = [ { lines: ['用文字塑造你的角色……'], className: 'text-app-ink' }, ] @@ -573,6 +602,87 @@ function AssetVisual({ ) } +function DirectionCandidatePicker({ + candidates, + selections, + disabled, + kind, + onSelect, +}: { + candidates: readonly QuickStartCandidate[] + selections: QuickStartDirectionSelections + disabled: boolean + kind: '角色方案' | '动作首帧' + onSelect: (direction: QuickStartCandidate['direction'], imageUrl: string) => void +}) { + const groups = groupCandidates(candidates) + const multipleDirections = groups.length > 1 + const imageName = kind === '角色方案' ? '角色图候选' : '动作首帧候选' + + return ( +
+ {groups.map((group) => ( +
+ {multipleDirections ? ( +

+ {DIRECTION_LABELS[group.direction]}方向 +

+ ) : null} +
+ {group.items.map((candidate, displayIndex) => { + const chosen = selections[group.direction] === candidate.imageUrl + const directionLabel = multipleDirections + ? `${DIRECTION_LABELS[group.direction]}方向` + : '' + return ( + + ) + })} +
+
+ ))} +
+ ) +} + function GenerationCanvas({ label }: { label: string }) { return (
(null) const [workflowConflict, setWorkflowConflict] = useState(false) - const [selectedCandidate, setSelectedCandidate] = useState(null) - const [selectedFirstFrame, setSelectedFirstFrame] = useState(null) + const [selectedCandidates, setSelectedCandidates] = useState({}) + const [selectedFirstFrames, setSelectedFirstFrames] = useState({}) const [actionDescription, setActionDescription] = useState('') - const [candidates, setCandidates] = useState([]) - const [firstFrameCandidates, setFirstFrameCandidates] = useState([]) + const [candidates, setCandidates] = useState([]) + const [firstFrameCandidates, setFirstFrameCandidates] = useState( + [], + ) const [actionFrames, setActionFrames] = useState([]) const [failedDirections, setFailedDirections] = useState([]) const [retryingDirection, setRetryingDirection] = useState(null) @@ -659,6 +771,8 @@ function QuickStartRun({ setRestoring(true) setSession(null) setRun(null) + setSelectedCandidates({}) + setSelectedFirstFrames({}) workflowConflictRef.current = false setError(null) setWorkflowConflict(false) @@ -851,6 +965,25 @@ function QuickStartRun({ const isFirstFrameSelecting = firstFrameStep?.status === 'active' && firstFrameStep.phase === 'selecting' const isFirstFrameFailed = firstFrameStep?.status === 'failed' + const candidateGroups = groupCandidates(candidates) + const firstFrameCandidateGroups = groupCandidates(firstFrameCandidates) + const templateSelections: QuickStartDirectionSelections = { + ...(templateStep?.selectedImageUrl ? { east: templateStep.selectedImageUrl } : {}), + ...(templateStep?.selectedImages ?? {}), + ...selectedCandidates, + } + const firstFrameSelections: QuickStartDirectionSelections = { + ...(firstFrameStep?.selectedFirstFrameUrl + ? { east: firstFrameStep.selectedFirstFrameUrl } + : {}), + ...(firstFrameStep?.selectedFirstFrameUrls ?? {}), + ...selectedFirstFrames, + } + const templateSelectionComplete = allDirectionsSelected(candidates, templateSelections) + const firstFrameSelectionComplete = allDirectionsSelected( + firstFrameCandidates, + firstFrameSelections, + ) async function interrupt() { try { @@ -881,14 +1014,14 @@ function QuickStartRun({ } async function confirmSelection() { - if (workflowConflictRef.current || !selectedCandidate || confirmingCandidate) return + if (workflowConflictRef.current || !templateSelectionComplete || confirmingCandidate) return setConfirmingCandidate(true) clearWorkflowError() try { if (!session) return - const updated = await session.confirmCandidate(selectedCandidate, actionDescription) + const updated = await session.confirmCandidate(templateSelections, actionDescription) setRun(updated) - setSelectedCandidate(null) + setSelectedCandidates({}) setActionDescription('') } catch (cause) { reportWorkflowError(cause, '确认选择失败') @@ -898,14 +1031,14 @@ function QuickStartRun({ } async function confirmFirstFrame() { - if (workflowConflictRef.current || !selectedFirstFrame || confirmingFirstFrame) return + if (workflowConflictRef.current || !firstFrameSelectionComplete || confirmingFirstFrame) return setConfirmingFirstFrame(true) clearWorkflowError() try { if (!session) return - const updated = await session.confirmFirstFrame(selectedFirstFrame) + const updated = await session.confirmFirstFrame(firstFrameSelections) setRun(updated) - setSelectedFirstFrame(null) + setSelectedFirstFrames({}) } catch (cause) { reportWorkflowError(cause, '确认动作首帧失败') } finally { @@ -997,13 +1130,13 @@ function QuickStartRun({ } const composerPlaceholder = isTemplateSelecting - ? selectedCandidate + ? templateSelectionComplete ? '描述这个角色接下来要做的动作…' - : '先从上面选择一个角色…' + : '请先为每个方向选择一个角色方案…' : isFirstFrameSelecting - ? selectedFirstFrame + ? firstFrameSelectionComplete ? '按发送确认这张首帧…' - : '先从上面选择一个动作首帧…' + : '请先为每个方向选择一个动作首帧…' : workflowHasFailure(run) ? '这次未完成,可以新建一次创作…' : canPublish @@ -1011,13 +1144,11 @@ function QuickStartRun({ : '制作中,完成后可以继续修改…' const composerCanSubmit = - (isTemplateSelecting && Boolean(selectedCandidate)) || - (isFirstFrameSelecting && Boolean(selectedFirstFrame)) + (isTemplateSelecting && templateSelectionComplete) || + (isFirstFrameSelecting && firstFrameSelectionComplete) const selectedTemplateUrl = templateStep?.selectedImageUrl const selectedFirstFrameUrl = firstFrameStep?.selectedFirstFrameUrl const requestedAction = firstFrameStep?.input.prompt || firstFrameStep?.input.name - const chosenTemplateUrl = selectedTemplateUrl ?? selectedCandidate - const chosenFirstFrameUrl = selectedFirstFrameUrl ?? selectedFirstFrame const characterTurnIsCurrent = !firstFrameStep const firstFrameTurnIsCurrent = Boolean(firstFrameStep) && actionStep?.status === 'locked' const actionTurnIsCurrent = Boolean(actionStep && actionStep.status !== 'locked') @@ -1049,47 +1180,25 @@ function QuickStartRun({ <> 1 + ? `已生成 ${candidateGroups.length} 个方向的角色方案。` + : `已生成 ${candidates.length} 个角色方案。`, isTemplateSelecting - ? '选择一个方案,再描述它接下来的动作。' + ? candidateGroups.length > 1 + ? '为每个方向选择一个方案,再描述它接下来的动作。' + : '选择一个方案,再描述它接下来的动作。' : '角色方案已确认。', ]} /> -
- {candidates.map((candidateUrl, index) => ( - - ))} -
+ + setSelectedCandidates((current) => ({ ...current, [direction]: imageUrl })) + } + /> - ))} -
- {selectedFirstFrame ? ( + + setSelectedFirstFrames((current) => ({ + ...current, + [direction]: imageUrl, + })) + } + /> + {firstFrameSelectionComplete ? ( +
+ ) + } + if (action.state.status === 'failure') { + return ( +
+

导入失败:{action.state.message}

+ {action.state.jobCode ? ( +

+ 阶段:{COCOS_PHASE_LABELS[action.state.phase ?? 'queued']} · 错误码: + {action.state.jobCode} · 回滚: + {action.state.rolledBack + ? '已完成' + : action.state.jobCode === 'IMPORT_ROLLBACK_FAILED' + ? '未完成,请检查工程资产' + : '未执行'} +

+ ) : null} +
+ ) + } + if (action.state.status === 'success') { + return ( +
+

已导入到当前 Cocos 工程

+

+ {action.state.result.projectName} · {action.state.result.animationCount} 个动作, + {action.state.result.frameCount} 帧 +

+
+ ) + } + return null +} + +function StateBanner({ state }: { state: ExportState }) { + if (state.status === 'working') { + return ( +

+ {PHASE_LABELS[state.phase]} +

+ ) + } + if (state.status === 'failure') { + return ( +

+ 导出失败:{state.message} +

+ ) + } + if (state.status === 'success') { + return

下载完成

+ } + return null +} + export function ExportPanel({ model, qualityIssueCount = 0, exporter = defaultExporter, + cocosExporter = defaultCocosExporter, + cocosImporter, + cocosPairer, + enableCocosExport = true, }: ExportPanelProps) { const plan = createAssetExportPlan(model) const { state, working, startExport } = useExportAction(model, exporter) + const cocos = useExportAction(model, cocosExporter) + const cocosImport = useCocosImportAction(model, cocosImporter, cocosPairer) + const cocosButtonLabel = + cocos.state.status === 'working' + ? PHASE_LABELS[cocos.state.phase] + : cocos.state.status === 'failure' + ? '重新导出 Cocos 包' + : cocos.state.status === 'success' + ? 'Cocos 包下载完成' + : '下载 Cocos 包' return (
@@ -117,27 +345,58 @@ export function ExportPanel({

) : null} - {state.status === 'working' ? ( -

- {PHASE_LABELS[state.phase]} -

- ) : state.status === 'failure' ? ( -

- 导出失败:{state.message} -

- ) : state.status === 'success' ? ( -

下载完成

- ) : null} + + {plan.length === 0 ?

当前包含角色母版

: null} + + {enableCocosExport ? ( +
+
+ + 一键导入 + +

Cocos Creator 3.8 适配

+
+

+ 首次安装并配对插件后,可直接写入当前 2D 工程并刷新 Prefab、动画和 SpriteFrame。 +

+ + + + +

+ 首次使用需要在 Creator 中安装全局插件并输入连接码。 +

+
+ ) : null}
) } @@ -145,11 +404,17 @@ export function ExportPanel({ export function ExportButton({ model, exporter = defaultExporter, + cocosExporter = defaultCocosExporter, + cocosImporter, + cocosPairer, className = '', idleLabel, pill = false, + enableCocosExport = true, }: ExportButtonProps) { const { state, working, startExport } = useExportAction(model, exporter) + const cocos = useExportAction(model, cocosExporter) + const cocosImport = useCocosImportAction(model, cocosImporter, cocosPairer) const label = state.status === 'working' ? PHASE_LABELS[state.phase] @@ -163,7 +428,7 @@ export function ExportButton({
+ + + {cocos.state.status === 'failure' ? ( + + Cocos 导出失败:{cocos.state.message} + + ) : null} + + ) : null}
) } diff --git a/frontend/src/features/export-package/index.ts b/frontend/src/features/export-package/index.ts index b7bfad81..e75960be 100644 --- a/frontend/src/features/export-package/index.ts +++ b/frontend/src/features/export-package/index.ts @@ -1,6 +1,11 @@ /** 将预览台当前角色资产打包下载;与发布到资产库是两件事。 */ export { ExportButton, ExportPanel } from './export-panel' -export type { ExportButtonProps, ExportPanelProps } from './export-panel' +export type { + CocosImporter, + CocosPairer, + ExportButtonProps, + ExportPanelProps, +} from './export-panel' export type { ExportAction, ExportAnchor, @@ -22,7 +27,32 @@ export { validateExportPackageModel, type GenericExportMetadata, } from './contract' -export { COCOS_TARGET_READINESS, toCocosAnchor } from './cocos-target' +export { + COCOS_IMPORT_SCHEMA_VERSION, + COCOS_TARGET_READINESS, + cocosCreatorTarget, + toCocosAnchor, +} from './cocos-target' +export { + COCOS_BRIDGE_PROTOCOL, + COCOS_BRIDGE_TOKEN_KEY, + CocosBridgeClient, + CocosBridgeError, + type CocosBridgeClientOptions, + type CocosBridgeErrorCode, + type CocosBridgeHealth, + type CocosImportJob, + type CocosImportPhase, + type CocosImportResult, +} from './cocos-bridge-client' +export { + importIntoCocos, + type CocosBridgeApi, + type CocosImportCache, + type CocosOneClickPhase, + type CocosPackageExporter, + type ImportIntoCocosOptions, +} from './cocos-one-click' export { createAssetExportPlan, exportGameAssets, diff --git a/tools/cocos-importer/README.md b/tools/cocos-importer/README.md new file mode 100644 index 00000000..1c465d12 --- /dev/null +++ b/tools/cocos-importer/README.md @@ -0,0 +1,80 @@ +# Windup → Cocos Creator 2D 一键导入 + +本目录包含两种导入方式: + +- Cocos Creator 扩展:网页配对后,点击一次即可完成打包、上传、事务写入、AssetDB 刷新、引用校验和 Prefab 定位。 +- Node CLI:用于 CI、离线排查或手动导入,不依赖 Creator 运行时。 + +支持 Cocos Creator `>=3.8.8 <3.9.0`,导入目标固定在当前工程的 `assets/windup-imports/`。 + +## 扩展安装与使用 + +1. 构建扩展: + + ```bash + cd tools/cocos-importer/extension + npm test + npm run build + npm run verify-package + ``` + +2. 在 Cocos Creator 3.8.x 的扩展管理器中导入并启用: + + `tools/cocos-importer/dist/windup-cocos-importer.zip` + +3. 打开目标 2D 工程,在 Creator 菜单选择“Windup → 显示连接码”。 +4. 在 Windup 网页输入 6 位连接码。连接码 5 分钟有效,成功后立即失效。 +5. 点击“一键导入 Cocos”。成功后 Creator 会定位到生成的 Prefab。 + +扩展只监听 `127.0.0.1:17832`。未配对网页只能读取协议与配对状态;工程名称、Creator 版本和工程打开状态只返回给已配对来源。上传接口校验来源、Bearer token、协议版本、请求 UUID、大小和 SHA-256。 + +## 导入行为 + +每次导入先在工程 `temp/windup-importer//` 中生成完整结果,再以目录替换方式写入: + +```text +assets/windup-imports/<角色>/<造型>/ +├── textures/<角色>-master.png +├── animations/ +│ ├── <动作>.anim +│ └── <动作>/<动作>_NNN.png +├── prefabs/<角色>-<造型>.prefab +├── cocos-import.json +├── meta.json +└── schema.json +``` + +若写入、刷新或引用校验失败,扩展会尽力恢复导入前目录,并把真实回滚结果返回网页。错误响应不会暴露本机绝对路径。ZIP 仅接受 Windup 使用的 STORED 格式,并校验 CRC、本地头、中央目录、重复路径和危险路径;同时限制 4096 个 ZIP 条目、32 MiB 单条目、128 个动作、4096 个总帧和 256 MiB 展开输出。 + +## CLI 回退 + +```bash +cd tools/cocos-importer +node bin/windup-cocos-import.mjs --out +node bin/windup-cocos-import.mjs --dry-run +``` + +覆盖已有输出目录时必须显式添加 `--force`。输出目录可复制到 Cocos 工程 `assets/`,但日常使用建议优先走扩展,以获得事务回滚、AssetDB 刷新和引用校验。 + +## 验证 + +```bash +cd tools/cocos-importer +npm test + +cd extension +npm test +npm run build +npm run verify-package + +cd .. +node test/verify-output.mjs 256 256 +``` + +2026-08-20 已用“网站看板娘 / 默认造型”真实 2D 资产在 Cocos Creator 3.8.8 完成导入:67 张 SpriteFrame、2 个 AnimationClip、64 个动作帧和 1 个 Prefab 均被 AssetDB 识别,输出引用校验通过。 + +## 边界 + +- 不支持 Cocos Creator 3.9+、3D 模型、骨骼动画或 DEFLATE ZIP。 +- 同一角色/造型采用整包替换,不做逐文件增量合并。 +- 浏览器无法连接扩展时,网页仍保留“下载 Cocos 包”作为回退。 diff --git a/tools/cocos-importer/bin/windup-cocos-import.mjs b/tools/cocos-importer/bin/windup-cocos-import.mjs new file mode 100644 index 00000000..792b314d --- /dev/null +++ b/tools/cocos-importer/bin/windup-cocos-import.mjs @@ -0,0 +1,229 @@ +#!/usr/bin/env node +// CLI:把 Windup 导出的 windup-*.zip 解到指定目录,产出按 Cocos Creator 结构 +// 组织的资源目录 + 元数据文件,供真实 Creator 实例继续验收。 +// +// 用法: +// node tools/cocos-importer/bin/windup-cocos-import.mjs --out +// node tools/cocos-importer/bin/windup-cocos-import.mjs # 默认 out=./cocos-import-output +// node tools/cocos-importer/bin/windup-cocos-import.mjs --out --dry-run + +import { + readFileSync, + writeFileSync, + mkdirSync, + existsSync, + rmSync, + statSync, + realpathSync, + readdirSync, +} from 'node:fs' +import { resolve, dirname, basename, join, relative } from 'node:path' +import { fileURLToPath } from 'node:url' + +import { prepareImport, prepareImportFromEntries } from '../src/import-core.js' + +const __dirname = dirname(fileURLToPath(import.meta.url)) + +function parseArgs(argv) { + const args = { input: null, out: null, dryRun: false, force: false } + for (let i = 2; i < argv.length; i += 1) { + const a = argv[i] + if (a === '--out' || a === '-o') { + args.out = argv[++i] + } else if (a === '--dry-run') { + args.dryRun = true + } else if (a === '--force') { + args.force = true + } else if (a === '--help' || a === '-h') { + args.help = true + } else if (!args.input) { + args.input = a + } + } + if (!args.out) args.out = resolve(process.cwd(), 'cocos-import-output') + return args +} + +function canonicalPath(path) { + if (existsSync(path)) return realpathSync.native(path) + const parent = dirname(path) + if (parent === path) return path + return join(canonicalPath(parent), basename(path)) +} + +function isSameOrAncestor(parent, child) { + const relativePath = resolve(parent) === resolve(child) ? '' : relative(parent, child) + return relativePath === '' || (!relativePath.startsWith('..') && !relativePath.includes(':')) +} + +function assertSafeOutputDir(outDir, inputPath) { + const output = canonicalPath(outDir) + const repoRoot = canonicalPath(resolve(__dirname, '..', '..', '..')) + const inputDir = canonicalPath(dirname(inputPath)) + const forbiddenAncestors = [ + ['仓库根目录或其祖先', repoRoot], + ['输入资产所在目录或其祖先', inputDir], + ] + for (const [label, forbidden] of forbiddenAncestors) { + if (isSameOrAncestor(output, forbidden)) { + throw new Error(`拒绝危险输出目录: ${outDir} 是${label}`) + } + } +} + +function readFramesDirectory(framesDir) { + if (basename(framesDir).toLowerCase() !== 'frames') { + throw new Error(`目录输入必须指向名为 frames 的逐帧目录: ${framesDir}`) + } + const packageRoot = dirname(framesDir) + const legacyMetaPath = join(packageRoot, 'meta.json') + if (!existsSync(legacyMetaPath)) { + throw new Error(`frames 同级资产包缺少 meta.json: ${legacyMetaPath}`) + } + + const rootDir = basename(packageRoot) + const entries = [] + const visit = (dir) => { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const fullPath = join(dir, entry.name) + if (entry.isSymbolicLink()) { + throw new Error(`frames 资产包不允许符号链接: ${fullPath}`) + } + if (entry.isDirectory()) { + visit(fullPath) + continue + } + if (!entry.isFile()) continue + const data = readFileSync(fullPath) + entries.push({ + rootDir, + relativePath: relative(packageRoot, fullPath).replaceAll('\\', '/'), + data: new Uint8Array(data.buffer, data.byteOffset, data.byteLength), + size: data.length, + }) + } + } + visit(packageRoot) + return entries +} + +function printHelp() { + // eslint-disable-next-line no-console + console.log(`Windup → Cocos Creator CLI 导入器 + +用法: + node tools/cocos-importer/bin/windup-cocos-import.mjs [--out ] [--dry-run] + +参数: + Windup 导出的 ZIP,或已解压资产包内的 frames 目录 + --out 输出目录(默认 ./cocos-import-output) + --dry-run 只打印计划,不写文件 + --force 允许删除并重建已存在的输出目录 + -h, --help 显示本帮助 + +产物: + //... + textures/ 主母版 PNG + animations//_NNN.png 每张帧 + atlas.png + prefabs/.prefab + .meta + cocos-import.json 原始 manifest 副本 + .meta.json 导入元信息 + +输出目录按 Cocos Creator 3.8.x 资产结构生成,可复制到工程的 assets/ 下。 +日常一键导入请安装 tools/cocos-importer/dist/windup-cocos-importer.zip。 +`) +} + +async function main() { + const args = parseArgs(process.argv) + if (args.help || !args.input) { + printHelp() + process.exit(args.help ? 0 : 2) + } + + const inputPath = resolve(process.cwd(), args.input) + if (!existsSync(inputPath)) { + throw new Error(`输入文件不存在: ${inputPath}`) + } + const stat = statSync(inputPath) + let prepared + if (stat.isFile()) { + const bytes = readFileSync(inputPath) + const stored = new Uint8Array(bytes.buffer, bytes.byteOffset, bytes.byteLength) + // eslint-disable-next-line no-console + console.log(`读取 ZIP: ${basename(inputPath)} (${stored.length} bytes)`) + prepared = prepareImport(stored) + } else if (stat.isDirectory()) { + const flat = readFramesDirectory(inputPath) + // eslint-disable-next-line no-console + console.log(`读取 frames 目录: ${inputPath} (${flat.length} 个文件)`) + prepared = prepareImportFromEntries(flat) + } else { + throw new Error(`输入既不是 ZIP 文件也不是 frames 目录: ${inputPath}`) + } + + const { manifest, plan } = prepared + // eslint-disable-next-line no-console + console.log( + ` manifest: schema=${manifest.schema_version} char=${manifest.package.character_name} ` + + `outfit=${manifest.package.outfit_name} actions=${manifest.actions.length}`, + ) + + // eslint-disable-next-line no-console + console.log(` 计划: ${plan.spriteFrames.length} 个 SpriteFrame,${plan.animations.length} 个动画`) + + const outDir = resolve(process.cwd(), args.out) + assertSafeOutputDir(outDir, inputPath) + + if (args.dryRun) { + // eslint-disable-next-line no-console + console.log(`\n[dry-run] 不会写任何文件,仅打印计划:`) + // eslint-disable-next-line no-console + console.log(` out dir: ${outDir}`) + // eslint-disable-next-line no-console + console.log(` textures: ${plan.spriteFrames.filter((s) => s.sourcePath.startsWith('character/')).length}`) + // eslint-disable-next-line no-console + console.log( + ` animations: ${plan.spriteFrames.filter((s) => s.sourcePath.startsWith('frames/') || s.sourcePath.startsWith('atlas/')).length}`, + ) + // eslint-disable-next-line no-console + console.log(` prefabs: 1 (${plan.prefab.cocosPath})`) + return + } + + if (existsSync(outDir) && !args.force) { + throw new Error(`输出目录已存在: ${outDir};如需覆盖请显式传 --force`) + } + if (existsSync(outDir) && !statSync(outDir).isDirectory()) { + throw new Error(`输出路径不是目录: ${outDir}`) + } + if (existsSync(outDir)) { + // eslint-disable-next-line no-console + console.log(` 清空已存在输出: ${outDir}`) + rmSync(outDir, { recursive: true, force: true }) + } + mkdirSync(outDir, { recursive: true }) + + let writtenBytes = 0 + let fileCount = 0 + for (const [path, bytes] of prepared.files) { + const dst = join(outDir, path) + mkdirSync(dirname(dst), { recursive: true }) + writeFileSync(dst, Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength)) + writtenBytes += bytes.length + fileCount += 1 + } + + // eslint-disable-next-line no-console + console.log( + `\n已写入 ${fileCount} 个文件,共 ${writtenBytes} bytes 到:\n ${outDir}\n\n` + + `下一步:将 "${plan.packFolder}" 目录复制到 Cocos Creator 工程的 assets/ 下,` + + `再用 Creator 实例完成导入与播放验收。`, + ) +} + +main().catch((err) => { + // eslint-disable-next-line no-console + console.error(`[error] ${err instanceof Error ? err.stack || err.message : String(err)}`) + process.exit(1) +}) diff --git a/tools/cocos-importer/dist/windup-cocos-importer.zip b/tools/cocos-importer/dist/windup-cocos-importer.zip new file mode 100644 index 0000000000000000000000000000000000000000..30d4219eb042301528839b03c372053c4b634c36 GIT binary patch literal 74598 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.8.8 <3.9.0", + "scripts": { + "test": "node --test test/*.test.mjs", + "build": "node scripts/build.mjs", + "verify-package": "node scripts/verify-package.mjs ../dist/windup-cocos-importer.zip" + }, + "contributions": { + "menu": [ + { + "path": "Windup", + "label": "显示连接码", + "message": "show-pairing-code" + }, + { + "path": "Windup", + "label": "连接状态", + "message": "show-connection-status" + } + ], + "messages": { + "show-pairing-code": { + "methods": ["showPairingCode"] + }, + "show-connection-status": { + "methods": ["showConnectionStatus"] + } + } + } +} diff --git a/tools/cocos-importer/extension/scripts/build.mjs b/tools/cocos-importer/extension/scripts/build.mjs new file mode 100644 index 00000000..c592cfcb --- /dev/null +++ b/tools/cocos-importer/extension/scripts/build.mjs @@ -0,0 +1,105 @@ +import { mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises' +import { dirname, join, relative, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const scriptDir = dirname(fileURLToPath(import.meta.url)) +const extensionDir = resolve(scriptDir, '..') +const importerDir = resolve(extensionDir, '..') +const outputDir = resolve(importerDir, 'dist') +const stagingDir = resolve(outputDir, '.extension-build') +const zipPath = resolve(outputDir, 'windup-cocos-importer.zip') + +function crc32(bytes) { + let crc = 0xffffffff + for (const byte of bytes) { + crc ^= byte + for (let bit = 0; bit < 8; bit += 1) crc = (crc >>> 1) ^ (0xedb88320 & -(crc & 1)) + } + return (crc ^ 0xffffffff) >>> 0 +} + +function storedZip(entries) { + const localParts = [] + const centralParts = [] + let offset = 0 + for (const entry of entries) { + const name = Buffer.from(entry.name.replaceAll('\\', '/')) + const data = Buffer.from(entry.data) + const checksum = crc32(data) + const local = Buffer.alloc(30) + local.writeUInt32LE(0x04034b50, 0) + local.writeUInt16LE(20, 4) + local.writeUInt16LE(0x0800, 6) + local.writeUInt32LE(checksum, 14) + local.writeUInt32LE(data.length, 18) + local.writeUInt32LE(data.length, 22) + local.writeUInt16LE(name.length, 26) + localParts.push(local, name, data) + + const central = Buffer.alloc(46) + central.writeUInt32LE(0x02014b50, 0) + central.writeUInt16LE(20, 4) + central.writeUInt16LE(20, 6) + central.writeUInt16LE(0x0800, 8) + central.writeUInt32LE(checksum, 16) + central.writeUInt32LE(data.length, 20) + central.writeUInt32LE(data.length, 24) + central.writeUInt16LE(name.length, 28) + central.writeUInt32LE(offset, 42) + centralParts.push(central, name) + offset += local.length + name.length + data.length + } + + const centralSize = centralParts.reduce((size, part) => size + part.length, 0) + const end = Buffer.alloc(22) + end.writeUInt32LE(0x06054b50, 0) + end.writeUInt16LE(entries.length, 8) + end.writeUInt16LE(entries.length, 10) + end.writeUInt32LE(centralSize, 12) + end.writeUInt32LE(offset, 16) + return Buffer.concat([...localParts, ...centralParts, end]) +} + +async function listFiles(root, directory = root) { + const files = [] + for (const entry of await readdir(directory, { withFileTypes: true })) { + const path = join(directory, entry.name) + if (entry.isDirectory()) files.push(...(await listFiles(root, path))) + else files.push({ name: relative(root, path).replaceAll('\\', '/'), data: await readFile(path) }) + } + return files +} + +async function copyRuntime(source, destination) { + await mkdir(destination, { recursive: true }) + for (const entry of await readdir(source, { withFileTypes: true })) { + if (!entry.isFile() || !entry.name.endsWith('.js')) continue + let contents = await readFile(join(source, entry.name), 'utf8') + if (entry.name === 'main.js') { + contents = contents.replace("'../../src/import-core.js'", "'./importer/import-core.js'") + } + await writeFile(join(destination, entry.name), contents) + } +} + +await rm(stagingDir, { recursive: true, force: true }) +await mkdir(join(stagingDir, 'dist', 'runtime'), { recursive: true }) +await copyRuntime(join(extensionDir, 'source'), join(stagingDir, 'dist', 'runtime')) +await copyRuntime(join(importerDir, 'src'), join(stagingDir, 'dist', 'runtime', 'importer')) + +const sourcePackage = JSON.parse(await readFile(join(extensionDir, 'package.json'), 'utf8')) +delete sourcePackage.scripts +delete sourcePackage.type +sourcePackage.main = './dist/main.js' +await writeFile(join(stagingDir, 'package.json'), `${JSON.stringify(sourcePackage, null, 2)}\n`) +await writeFile( + join(stagingDir, 'dist', 'main.js'), + `"use strict"\nlet runtime\nasync function getRuntime() { return runtime ??= import('./runtime/main.js') }\nexports.methods = {\n async showPairingCode() { return (await getRuntime()).methods.showPairingCode() },\n async showConnectionStatus() { return (await getRuntime()).methods.showConnectionStatus() },\n}\nexports.load = async function load() { return (await getRuntime()).load() }\nexports.unload = async function unload() { return (await getRuntime()).unload() }\n`, +) +await writeFile(join(stagingDir, 'dist', 'runtime', 'package.json'), '{"type":"module"}\n') + +const files = await listFiles(stagingDir) +await mkdir(outputDir, { recursive: true }) +await writeFile(zipPath, storedZip(files)) +await rm(stagingDir, { recursive: true, force: true }) +console.log(zipPath) diff --git a/tools/cocos-importer/extension/scripts/verify-package.mjs b/tools/cocos-importer/extension/scripts/verify-package.mjs new file mode 100644 index 00000000..c06e3649 --- /dev/null +++ b/tools/cocos-importer/extension/scripts/verify-package.mjs @@ -0,0 +1,30 @@ +import { readFile } from 'node:fs/promises' +import { resolve } from 'node:path' + +import { readStoredZip } from '../../src/zip-reader.js' + +const zipPath = resolve(process.argv[2] ?? '../dist/windup-cocos-importer.zip') +const entries = readStoredZip(await readFile(zipPath)) +const names = entries.map((entry) => entry.name) +for (const required of ['package.json', 'dist/main.js', 'dist/runtime/main.js', 'dist/runtime/importer/import-core.js']) { + if (!names.includes(required)) throw new Error(`PACKAGE_FILE_MISSING: ${required}`) +} + +const forbiddenName = names.find((name) => /(^|\/)(test|temp|\.tmp|node_modules)(\/|$)/i.test(name)) +if (forbiddenName) throw new Error(`PACKAGE_FORBIDDEN_FILE: ${forbiddenName}`) + +for (const entry of entries) { + const text = new TextDecoder().decode(entry.data) + if (/[A-Z]:\\|\/Users\/|tokenDigest"\s*:\s*"[0-9a-f]{64}"/i.test(text)) { + throw new Error(`PACKAGE_LOCAL_OR_SECRET_DATA: ${entry.name}`) + } +} + +const metadata = JSON.parse(new TextDecoder().decode(entries.find((entry) => entry.name === 'package.json').data)) +if (metadata.package_version !== 2 || metadata.main !== './dist/main.js') { + throw new Error('PACKAGE_METADATA_INVALID') +} +if (metadata.contributions.menu.some((item) => item.path !== 'Windup')) { + throw new Error('PACKAGE_MENU_PATH_INVALID') +} +console.log(`OK: ${zipPath} (${entries.length} files)`) diff --git a/tools/cocos-importer/extension/source/creator-assets.js b/tools/cocos-importer/extension/source/creator-assets.js new file mode 100644 index 00000000..6aa1d029 --- /dev/null +++ b/tools/cocos-importer/extension/source/creator-assets.js @@ -0,0 +1,24 @@ +export class CreatorAssets { + #Editor + + constructor(Editor) { + if (!Editor?.Message?.request || !Editor?.Message?.send) { + throw new Error('CREATOR_MESSAGE_API_UNAVAILABLE') + } + this.#Editor = Editor + } + + refresh(dbUrl) { + return this.#Editor.Message.request('asset-db', 'refresh-asset', dbUrl) + } + + query(dbUrl) { + return this.#Editor.Message.request('asset-db', 'query-asset-info', dbUrl) + } + + async reveal(dbUrl) { + const asset = await this.query(dbUrl) + if (!asset?.uuid) throw new Error(`CREATOR_ASSET_NOT_FOUND: ${dbUrl}`) + this.#Editor.Message.send('assets', 'twinkle', asset.uuid) + } +} diff --git a/tools/cocos-importer/extension/source/http-server.js b/tools/cocos-importer/extension/source/http-server.js new file mode 100644 index 00000000..264bbdf5 --- /dev/null +++ b/tools/cocos-importer/extension/source/http-server.js @@ -0,0 +1,208 @@ +import { createHash } from 'node:crypto' +import { createServer } from 'node:http' + +import { PROTOCOL } from './protocol.js' + +const DEFAULT_MAX_UPLOAD_BYTES = 256 * 1024 * 1024 +const REQUEST_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i +const ALLOWED_HEADERS = [ + 'Authorization', + 'Content-Type', + 'X-Windup-Protocol', + 'X-Windup-Request-Id', + 'X-Windup-SHA256', +].join(', ') + +function cors(origin) { + return origin ? { 'Access-Control-Allow-Origin': origin, Vary: 'Origin' } : {} +} + +function json(response, status, body, headers = {}) { + response.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', ...headers }) + response.end(JSON.stringify(body)) +} + +function error(response, status, code, headers = {}) { + json(response, status, { protocol: PROTOCOL, error: { code } }, headers) +} + +async function readBody(request, maxBytes) { + const contentLength = request.headers['content-length'] + if (contentLength !== undefined && Number(contentLength) > maxBytes) { + throw new Error('UPLOAD_TOO_LARGE') + } + const chunks = [] + let size = 0 + for await (const chunk of request) { + size += chunk.length + if (size > maxBytes) throw new Error('UPLOAD_TOO_LARGE') + chunks.push(chunk) + } + return Buffer.concat(chunks) +} + +function bearer(request) { + const value = request.headers.authorization + return typeof value === 'string' && value.startsWith('Bearer ') ? value.slice(7) : null +} + +function requestPath(request) { + return new URL(request.url, 'http://127.0.0.1').pathname +} + +async function authenticate(request, response, pairing) { + const origin = request.headers.origin + if (!(await pairing.isOriginAllowed(origin))) { + error(response, 403, 'ORIGIN_FORBIDDEN') + return null + } + const headers = cors(origin) + const token = bearer(request) + if (!token || !(await pairing.authorize(origin, token))) { + error(response, 401, 'UNAUTHORIZED', headers) + return null + } + if (request.headers['x-windup-protocol'] !== PROTOCOL) { + error(response, 426, 'PROTOCOL_INCOMPATIBLE', headers) + return null + } + return { origin, headers } +} + +export async function startServer({ + host = '127.0.0.1', + port = 17_832, + pairing, + jobs, + health, + maxUploadBytes = DEFAULT_MAX_UPLOAD_BYTES, +}) { + if (host !== '127.0.0.1') throw new Error('BRIDGE_HOST_FORBIDDEN') + + const nodeServer = createServer(async (request, response) => { + try { + const path = requestPath(request) + const origin = request.headers.origin + + if (request.method === 'GET' && path === '/v1/health') { + const paired = await pairing.isOriginAllowed(origin) + const body = paired + ? { protocol: PROTOCOL, ...(await health()), paired: true } + : { protocol: PROTOCOL, paired: false } + json(response, 200, body, cors(origin)) + return + } + + if (request.method === 'OPTIONS' && path === '/v1/pair') { + response.writeHead(204, { + ...cors(origin), + 'Access-Control-Allow-Methods': 'POST, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type', + 'Access-Control-Max-Age': '600', + }) + response.end() + return + } + + if (request.method === 'POST' && path === '/v1/pair') { + const headers = cors(origin) + let payload + try { + payload = JSON.parse((await readBody(request, 1024)).toString('utf8')) + } catch (cause) { + error(response, cause.message === 'UPLOAD_TOO_LARGE' ? 413 : 400, 'PAIR_REQUEST_INVALID', headers) + return + } + try { + const token = await pairing.pair(payload.code, origin) + json(response, 200, { protocol: PROTOCOL, token }, headers) + } catch (cause) { + const locked = cause.message === 'PAIR_CODE_LOCKED' + error(response, locked ? 429 : 400, cause.message, headers) + } + return + } + + const importPreflight = path === '/v1/imports' || /^\/v1\/imports\/[^/]+$/.test(path) + if (request.method === 'OPTIONS' && importPreflight) { + if (!(await pairing.isOriginAllowed(origin))) { + error(response, 403, 'ORIGIN_FORBIDDEN') + return + } + const methods = path === '/v1/imports' ? 'POST, OPTIONS' : 'GET, OPTIONS' + response.writeHead(204, { + ...cors(origin), + 'Access-Control-Allow-Methods': methods, + 'Access-Control-Allow-Headers': ALLOWED_HEADERS, + 'Access-Control-Max-Age': '600', + }) + response.end() + return + } + + const jobMatch = path.match(/^\/v1\/imports\/([^/]+)$/) + if (request.method === 'GET' && jobMatch) { + const auth = await authenticate(request, response, pairing) + if (!auth) return + const job = await jobs.get(decodeURIComponent(jobMatch[1])) + if (!job) { + error(response, 404, 'IMPORT_JOB_NOT_FOUND', auth.headers) + return + } + json(response, 200, job, auth.headers) + return + } + + if (request.method === 'POST' && path === '/v1/imports') { + const auth = await authenticate(request, response, pairing) + if (!auth) return + if (request.headers['content-type'] !== 'application/zip') { + error(response, 415, 'CONTENT_TYPE_UNSUPPORTED', auth.headers) + return + } + const requestId = request.headers['x-windup-request-id'] + const expectedSha = request.headers['x-windup-sha256'] + if (!/^[0-9a-f]{64}$/.test(expectedSha ?? '') || !REQUEST_ID.test(requestId ?? '')) { + error(response, 400, 'IMPORT_HEADERS_INVALID', auth.headers) + return + } + + let zipBytes + try { + zipBytes = await readBody(request, maxUploadBytes) + } catch { + error(response, 413, 'UPLOAD_TOO_LARGE', auth.headers) + return + } + const actualSha = createHash('sha256').update(zipBytes).digest('hex') + if (actualSha !== expectedSha) { + error(response, 400, 'UPLOAD_DIGEST_MISMATCH', auth.headers) + return + } + const { jobId } = await jobs.submit({ requestId, zipBytes, sha256: actualSha }) + json(response, 202, { protocol: PROTOCOL, jobId }, auth.headers) + return + } + + error(response, 404, 'NOT_FOUND') + } catch { + if (!response.headersSent) error(response, 500, 'BRIDGE_INTERNAL_ERROR') + else response.destroy() + } + }) + + try { + await new Promise((resolve, reject) => { + nodeServer.once('error', reject) + nodeServer.listen(port, host, resolve) + }) + } catch (cause) { + if (cause?.code === 'EADDRINUSE') throw new Error('BRIDGE_PORT_IN_USE') + throw cause + } + + return { + address: () => nodeServer.address(), + close: () => new Promise((resolve, reject) => nodeServer.close((error) => (error ? reject(error) : resolve()))), + } +} diff --git a/tools/cocos-importer/extension/source/import-job.js b/tools/cocos-importer/extension/source/import-job.js new file mode 100644 index 00000000..726bc6ef --- /dev/null +++ b/tools/cocos-importer/extension/source/import-job.js @@ -0,0 +1,228 @@ +import { createHash } from 'node:crypto' +import { lstat, mkdir, rename, rm, rmdir, writeFile } from 'node:fs/promises' +import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path' + +const REQUEST_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i +const FILE_SYSTEM = { lstat, mkdir, rename, rm, rmdir, writeFile } +const PUBLIC_IMPORT_CODES = new Set([ + 'IMPORT_ABORTED', + 'IMPORT_PATH_FORBIDDEN', + 'IMPORT_PATH_SYMLINK', + 'IMPORT_SHA256_MISMATCH', + 'IMPORT_VERIFY_FAILED', +]) + +async function pathExists(fileSystem, path) { + try { + await fileSystem.lstat(path) + return true + } catch (cause) { + if (cause?.code === 'ENOENT') return false + throw cause + } +} + +function assertWithin(root, path) { + const offset = relative(resolve(root), resolve(path)) + if (offset === '' || (!offset.startsWith(`..${sep}`) && offset !== '..' && !isAbsolute(offset))) return + throw new Error(`IMPORT_PATH_FORBIDDEN: ${path}`) +} + +async function assertNoSymlinkPath(fileSystem, root, path) { + assertWithin(root, path) + const segments = relative(resolve(root), resolve(path)).split(sep).filter(Boolean) + let current = resolve(root) + for (const segment of ['', ...segments]) { + if (segment) current = join(current, segment) + try { + if ((await fileSystem.lstat(current)).isSymbolicLink()) throw new Error(`IMPORT_PATH_SYMLINK: ${current}`) + } catch (cause) { + if (cause?.code === 'ENOENT') return + throw cause + } + } +} + +function safeRelativePath(value) { + if (typeof value !== 'string' || value.includes('\\')) throw new Error(`IMPORT_PATH_FORBIDDEN: ${value}`) + const segments = value.split('/') + if (segments.some((segment) => segment === '' || segment === '.' || segment === '..')) { + throw new Error(`IMPORT_PATH_FORBIDDEN: ${value}`) + } + return segments.join(sep) +} + +function throwIfAborted(signal) { + if (signal?.aborted) throw new Error('IMPORT_ABORTED') +} + +async function removeEmptyDirectory(fileSystem, path) { + try { + await fileSystem.rmdir(path) + } catch (cause) { + if (cause?.code !== 'ENOENT' && cause?.code !== 'ENOTEMPTY') throw cause + } +} + +function importFailure(cause, rolledBack, overrideCode) { + const message = cause instanceof Error ? cause.message : String(cause) + const candidate = /^([A-Z][A-Z0-9_]+)(?::|$)/.exec(message)?.[1] + const error = new Error(message, { cause }) + error.code = overrideCode ?? (PUBLIC_IMPORT_CODES.has(candidate) ? candidate : 'IMPORT_FAILED') + error.rolledBack = rolledBack + return error +} + +export class ImportJobRunner { + #projectPath + #assets + #prepareImport + #fileSystem + #requests = new Map() + + constructor({ projectPath, assets, prepareImport, fileSystem = {} }) { + this.#projectPath = resolve(projectPath) + this.#assets = assets + this.#prepareImport = prepareImport + this.#fileSystem = { ...FILE_SYSTEM, ...fileSystem } + } + + run(request) { + const existing = this.#requests.get(request.requestId) + if (existing) { + if (existing.sha256 !== request.sha256) return Promise.reject(new Error('IMPORT_REQUEST_ID_CONFLICT')) + return existing.promise + } + const promise = this.#execute(request) + this.#requests.set(request.requestId, { sha256: request.sha256, promise }) + void promise.finally(() => this.#requests.delete(request.requestId)).catch(() => {}) + return promise + } + + async #execute({ requestId, zipBytes, sha256, onPhase = () => {}, signal }) { + if (!REQUEST_ID.test(requestId)) throw new Error('IMPORT_REQUEST_ID_INVALID') + const actualSha = createHash('sha256').update(zipBytes).digest('hex') + if (actualSha !== sha256) throw new Error('IMPORT_SHA256_MISMATCH') + + throwIfAborted(signal) + onPhase('converting') + const prepared = this.#prepareImport(zipBytes) + const packRelative = safeRelativePath(prepared.packFolder) + if (!prepared.packFolder.startsWith('windup-imports/')) { + throw new Error(`IMPORT_PATH_FORBIDDEN: ${prepared.packFolder}`) + } + + const assetsRoot = join(this.#projectPath, 'assets') + const importRoot = join(assetsRoot, 'windup-imports') + const destination = join(assetsRoot, packRelative) + assertWithin(importRoot, destination) + + const tempRoot = join(this.#projectPath, 'temp', 'windup-importer') + const transactionRoot = join(tempRoot, requestId) + const outputRoot = join(transactionRoot, 'output') + const stagedPack = join(outputRoot, packRelative) + const backup = join(transactionRoot, 'backup') + assertWithin(tempRoot, transactionRoot) + + let backupCreated = false + let installed = false + const dbFolder = `db://assets/${prepared.packFolder}` + const prefabDbUrl = `db://assets/${prepared.plan.prefab.cocosPath}` + + try { + await assertNoSymlinkPath(this.#fileSystem, this.#projectPath, destination) + await assertNoSymlinkPath(this.#fileSystem, this.#projectPath, transactionRoot) + await this.#fileSystem.rm(transactionRoot, { recursive: true, force: true }) + for (const [filePath, bytes] of prepared.files) { + throwIfAborted(signal) + const fileRelative = safeRelativePath(filePath) + if (filePath !== prepared.packFolder && !filePath.startsWith(`${prepared.packFolder}/`)) { + throw new Error(`IMPORT_PATH_FORBIDDEN: ${filePath}`) + } + const stagedFile = join(outputRoot, fileRelative) + assertWithin(stagedPack, stagedFile) + await this.#fileSystem.mkdir(dirname(stagedFile), { recursive: true }) + await this.#fileSystem.writeFile(stagedFile, bytes) + } + + throwIfAborted(signal) + onPhase('writing') + await this.#fileSystem.mkdir(dirname(destination), { recursive: true }) + if (await pathExists(this.#fileSystem, destination)) { + await this.#fileSystem.rename(destination, backup) + backupCreated = true + } + await this.#fileSystem.rename(stagedPack, destination) + installed = true + + throwIfAborted(signal) + onPhase('refreshing') + await this.#assets.refresh(dbFolder) + + throwIfAborted(signal) + onPhase('verifying') + const requiredDbUrls = [ + prefabDbUrl, + ...prepared.plan.animations.map( + (animation) => `db://assets/${prepared.packFolder}/animations/${animation.name}.anim`, + ), + ] + for (const dbUrl of requiredDbUrls) { + throwIfAborted(signal) + if (!(await this.#assets.query(dbUrl))) throw new Error(`IMPORT_VERIFY_FAILED: ${dbUrl}`) + } + await this.#assets.reveal(prefabDbUrl) + + await this.#fileSystem.rm(transactionRoot, { recursive: true, force: true }) + await removeEmptyDirectory(this.#fileSystem, tempRoot) + return { + dbUrl: prefabDbUrl, + animationCount: prepared.summary.animationCount, + frameCount: prepared.summary.frameCount, + } + } catch (cause) { + const rollbackNeeded = installed || backupCreated + const rollbackErrors = [] + if (installed) { + try { + await this.#fileSystem.rm(destination, { recursive: true, force: true }) + } catch (error) { + rollbackErrors.push(error) + } + } + if (backupCreated) { + try { + if (await pathExists(this.#fileSystem, backup)) { + await this.#fileSystem.mkdir(dirname(destination), { recursive: true }) + await this.#fileSystem.rename(backup, destination) + } else { + rollbackErrors.push(new Error('IMPORT_BACKUP_MISSING')) + } + } catch (error) { + rollbackErrors.push(error) + } + } + if (rollbackNeeded) { + try { + await this.#assets.refresh(dbFolder) + } catch (error) { + rollbackErrors.push(error) + } + } + try { + await this.#fileSystem.rm(transactionRoot, { recursive: true, force: true }) + } catch { + // Cleanup is best-effort and does not change whether the user asset was restored. + } + try { + await removeEmptyDirectory(this.#fileSystem, tempRoot) + } catch { + // Keep any stale transaction data for diagnosis; user assets are already restored. + } + if (rollbackErrors.length > 0) { + throw importFailure(cause, false, 'IMPORT_ROLLBACK_FAILED') + } + throw importFailure(cause, rollbackNeeded) + } + } +} diff --git a/tools/cocos-importer/extension/source/import-jobs.js b/tools/cocos-importer/extension/source/import-jobs.js new file mode 100644 index 00000000..be7db391 --- /dev/null +++ b/tools/cocos-importer/extension/source/import-jobs.js @@ -0,0 +1,103 @@ +import { randomUUID } from 'node:crypto' + +import { PROTOCOL } from './protocol.js' + +function publicJob(job) { + const output = { + protocol: PROTOCOL, + jobId: job.jobId, + status: job.status, + phase: job.phase, + } + if (job.result) output.result = job.result + if (job.error) output.error = job.error + return output +} + +const PUBLIC_ERRORS = new Map([ + ['IMPORT_ABORTED', '导入已取消'], + ['IMPORT_PATH_FORBIDDEN', '导入包包含不安全路径'], + ['IMPORT_PATH_SYMLINK', '导入目标包含符号链接'], + ['IMPORT_SHA256_MISMATCH', '导入包完整性校验失败'], + ['IMPORT_VERIFY_FAILED', '导入结果校验失败'], + ['IMPORT_ROLLBACK_FAILED', '导入失败且无法完整回滚,请检查工程资产'], +]) + +function publicError(cause) { + const code = cause instanceof Error && PUBLIC_ERRORS.has(cause.code) ? cause.code : 'IMPORT_FAILED' + return { + code, + message: PUBLIC_ERRORS.get(code) ?? 'Cocos 导入失败', + rolledBack: cause instanceof Error && cause.rolledBack === true, + } +} + +export function createImportJobs({ runner, projectName, randomUUID: createId = randomUUID, maxJobs = 20 }) { + const jobs = new Map() + const requests = new Map() + let closed = false + + return { + async submit(request) { + if (closed) throw new Error('IMPORT_SERVICE_CLOSED') + const existing = requests.get(request.requestId) + if (existing) { + if (existing.sha256 !== request.sha256) throw new Error('IMPORT_REQUEST_ID_CONFLICT') + return { jobId: existing.jobId } + } + + for (const [jobId, job] of jobs) { + if (jobs.size < maxJobs) break + if (job.status === 'running') continue + jobs.delete(jobId) + requests.delete(job.requestId) + } + if (jobs.size >= maxJobs) throw new Error('IMPORT_QUEUE_FULL') + + const jobId = createId() + const controller = new AbortController() + const job = { jobId, requestId: request.requestId, status: 'running', phase: 'converting', controller } + jobs.set(jobId, job) + requests.set(request.requestId, { jobId, sha256: request.sha256 }) + + void runner + .run({ + ...request, + signal: controller.signal, + onPhase(phase) { + if (job.status === 'running') job.phase = phase + }, + }) + .then((result) => { + if (job.status !== 'running') return + if (controller.signal.aborted) { + job.status = 'failed' + job.error = { code: 'IMPORT_ABORTED', message: '导入已取消', rolledBack: false } + return + } + job.status = 'completed' + job.phase = 'verifying' + job.result = { projectName: projectName(), ...result } + }) + .catch((cause) => { + if (job.status !== 'running') return + job.status = 'failed' + job.error = publicError(cause) + }) + return { jobId } + }, + + async get(jobId) { + const job = jobs.get(jobId) + return job ? publicJob(job) : null + }, + + close() { + closed = true + for (const job of jobs.values()) { + if (job.status !== 'running') continue + job.controller.abort() + } + }, + } +} diff --git a/tools/cocos-importer/extension/source/main.js b/tools/cocos-importer/extension/source/main.js new file mode 100644 index 00000000..d3c9256e --- /dev/null +++ b/tools/cocos-importer/extension/source/main.js @@ -0,0 +1,101 @@ +import { startServer } from './http-server.js' +import { PairingStore } from './pairing-store.js' +import { CreatorAssets } from './creator-assets.js' +import { ImportJobRunner } from './import-job.js' +import { createImportJobs } from './import-jobs.js' +import { prepareImport } from '../../src/import-core.js' + +const PACKAGE_NAME = 'windup-cocos-importer' +const PORT = 17_832 + +let activeExtension = null + +function dialog(Editor, title, message) { + if (Editor.Dialog?.info) return Editor.Dialog.info(message, { title }) + console.info(`[${title}] ${message}`) +} + +function profileAdapter(Editor) { + return { + load: () => Editor.Profile.getConfig(PACKAGE_NAME, 'pairing', 'global'), + save: (value) => Editor.Profile.setConfig(PACKAGE_NAME, 'pairing', value, 'global'), + } +} + +export function createExtension({ Editor, jobs, serverFactory = startServer }) { + const pairing = new PairingStore({ profile: profileAdapter(Editor) }) + const activeJobs = + jobs ?? + createImportJobs({ + runner: new ImportJobRunner({ + projectPath: Editor.Project.path, + assets: new CreatorAssets(Editor), + prepareImport, + }), + projectName: () => Editor.Project.name, + }) + let server = null + + return { + pairing, + async load() { + try { + server = await serverFactory({ + host: '127.0.0.1', + port: PORT, + pairing, + jobs: activeJobs, + health: async () => ({ + creatorVersion: Editor.App.version, + projectName: Editor.Project.name, + projectOpen: Boolean(Editor.Project.path), + }), + }) + console.info(`[Windup] Cocos 一键导入服务已启动:http://127.0.0.1:${PORT}`) + } catch (cause) { + const message = cause?.message === 'BRIDGE_PORT_IN_USE' ? 'BRIDGE_PORT_IN_USE' : 'BRIDGE_START_FAILED' + console.error(`[Windup] ${message}`, cause) + throw cause + } + }, + async unload() { + const current = server + server = null + await current?.close() + activeJobs.close?.() + }, + async showPairingCode() { + const code = pairing.createCode() + await dialog(Editor, 'Windup 一键导入', `连接码:${code}\n有效期 5 分钟,请在 Windup 网页中输入。`) + return code + }, + async showConnectionStatus() { + const pairingValue = await profileAdapter(Editor).load() + const status = pairingValue?.origin + ? `已授权网页:${pairingValue.origin}\n服务地址:http://127.0.0.1:${PORT}` + : '尚未授权 Windup 网页。请先选择“显示连接码”。' + await dialog(Editor, 'Windup 连接状态', status) + return status + }, + } +} + +export const methods = { + showPairingCode() { + return activeExtension?.showPairingCode() + }, + showConnectionStatus() { + return activeExtension?.showConnectionStatus() + }, +} + +export async function load() { + activeExtension = createExtension({ Editor: globalThis.Editor }) + await activeExtension.load() +} + +export async function unload() { + const current = activeExtension + activeExtension = null + await current?.unload() +} diff --git a/tools/cocos-importer/extension/source/pairing-store.js b/tools/cocos-importer/extension/source/pairing-store.js new file mode 100644 index 00000000..33be63e8 --- /dev/null +++ b/tools/cocos-importer/extension/source/pairing-store.js @@ -0,0 +1,89 @@ +import { createHash, randomBytes, randomInt, timingSafeEqual } from 'node:crypto' + +const CODE_TTL_MS = 5 * 60_000 +const MAX_ATTEMPTS = 5 + +function digest(value) { + return createHash('sha256').update(value).digest('hex') +} + +function validOrigin(origin) { + if (typeof origin !== 'string') return false + try { + const parsed = new URL(origin) + return (parsed.protocol === 'http:' || parsed.protocol === 'https:') && parsed.origin === origin + } catch { + return false + } +} + +function equalDigest(left, right) { + const leftBuffer = Buffer.from(left) + const rightBuffer = Buffer.from(right) + return leftBuffer.length === rightBuffer.length && timingSafeEqual(leftBuffer, rightBuffer) +} + +export class PairingStore { + #profile + #now + #randomCode + #randomToken + #code = null + + constructor({ + profile, + now = Date.now, + randomCode = () => randomInt(0, 1_000_000).toString().padStart(6, '0'), + randomToken = () => randomBytes(32).toString('hex'), + }) { + if (!profile?.load || !profile?.save) throw new Error('PAIR_PROFILE_REQUIRED') + this.#profile = profile + this.#now = now + this.#randomCode = randomCode + this.#randomToken = randomToken + } + + createCode() { + const value = this.#randomCode() + if (!/^\d{6}$/.test(value)) throw new Error('PAIR_CODE_INVALID_FORMAT') + this.#code = { value, expiresAt: this.#now() + CODE_TTL_MS, attempts: 0 } + return value + } + + hasActiveCode() { + return this.#code !== null && this.#code.attempts < MAX_ATTEMPTS && this.#now() <= this.#code.expiresAt + } + + async pair(code, origin) { + if (!this.hasActiveCode()) { + this.#code = null + throw new Error('PAIR_CODE_EXPIRED') + } + if (!validOrigin(origin)) throw new Error('PAIR_ORIGIN_INVALID') + if (code !== this.#code.value) { + this.#code.attempts += 1 + if (this.#code.attempts >= MAX_ATTEMPTS) throw new Error('PAIR_CODE_LOCKED') + throw new Error('PAIR_CODE_INVALID') + } + + const token = this.#randomToken() + await this.#profile.save({ origin, tokenDigest: digest(token) }) + this.#code = null + return token + } + + async authorize(origin, token) { + if (!validOrigin(origin) || typeof token !== 'string') return false + const pairing = await this.#profile.load() + return ( + pairing?.origin === origin && + typeof pairing.tokenDigest === 'string' && + equalDigest(pairing.tokenDigest, digest(token)) + ) + } + + async isOriginAllowed(origin) { + if (!validOrigin(origin)) return false + return (await this.#profile.load())?.origin === origin + } +} diff --git a/tools/cocos-importer/extension/source/protocol.js b/tools/cocos-importer/extension/source/protocol.js new file mode 100644 index 00000000..d19cb4f3 --- /dev/null +++ b/tools/cocos-importer/extension/source/protocol.js @@ -0,0 +1 @@ +export const PROTOCOL = 'windup-cocos-bridge/1.0.0' diff --git a/tools/cocos-importer/extension/test/creator-assets.test.mjs b/tools/cocos-importer/extension/test/creator-assets.test.mjs new file mode 100644 index 00000000..85b93272 --- /dev/null +++ b/tools/cocos-importer/extension/test/creator-assets.test.mjs @@ -0,0 +1,48 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' + +import { CreatorAssets } from '../source/creator-assets.js' + +test('CreatorAssets isolates the Creator 3.8.8 AssetDB message contract', async () => { + const calls = [] + const Editor = { + Message: { + async request(...args) { + calls.push(['request', ...args]) + if (args[1] === 'query-asset-info') return { uuid: 'asset-uuid', url: args[2] } + return true + }, + send(...args) { + calls.push(['send', ...args]) + }, + }, + } + const assets = new CreatorAssets(Editor) + + await assets.refresh('db://assets/windup-imports/Hero/Ranger') + assert.deepEqual(await assets.query('db://assets/windup-imports/Hero/Ranger/p.prefab'), { + uuid: 'asset-uuid', + url: 'db://assets/windup-imports/Hero/Ranger/p.prefab', + }) + await assets.reveal('db://assets/windup-imports/Hero/Ranger/p.prefab') + + assert.deepEqual(calls, [ + ['request', 'asset-db', 'refresh-asset', 'db://assets/windup-imports/Hero/Ranger'], + ['request', 'asset-db', 'query-asset-info', 'db://assets/windup-imports/Hero/Ranger/p.prefab'], + ['request', 'asset-db', 'query-asset-info', 'db://assets/windup-imports/Hero/Ranger/p.prefab'], + ['send', 'assets', 'twinkle', 'asset-uuid'], + ]) +}) + +test('CreatorAssets rejects missing assets before attempting to reveal them', async () => { + const Editor = { + Message: { + request: async () => null, + send: () => assert.fail('must not reveal a missing asset'), + }, + } + await assert.rejects( + () => new CreatorAssets(Editor).reveal('db://assets/missing.prefab'), + /CREATOR_ASSET_NOT_FOUND/, + ) +}) diff --git a/tools/cocos-importer/extension/test/http-server.test.mjs b/tools/cocos-importer/extension/test/http-server.test.mjs new file mode 100644 index 00000000..744d5b4f --- /dev/null +++ b/tools/cocos-importer/extension/test/http-server.test.mjs @@ -0,0 +1,237 @@ +import { afterEach, test } from 'node:test' +import assert from 'node:assert/strict' + +import { startServer } from '../source/http-server.js' +import { PairingStore } from '../source/pairing-store.js' +import { PROTOCOL } from '../source/protocol.js' + +const servers = [] + +afterEach(async () => { + await Promise.all(servers.splice(0).map((server) => server.close())) +}) + +function dependencies() { + const profile = { + value: null, + async load() { + return this.value + }, + async save(value) { + this.value = value + }, + } + const pairing = new PairingStore({ + profile, + randomCode: () => '123456', + randomToken: () => 'a'.repeat(64), + }) + const submitted = [] + const jobs = { + async submit(request) { + submitted.push(request) + return { jobId: 'job-1' } + }, + async get(jobId) { + return { + protocol: PROTOCOL, + jobId, + status: 'completed', + phase: 'verifying', + result: { projectName: 'Game', dbUrl: 'db://assets/p.prefab', animationCount: 2, frameCount: 64 }, + } + }, + } + return { pairing, jobs, submitted } +} + +async function running(options = {}) { + const deps = dependencies() + const server = await startServer({ + host: '127.0.0.1', + port: 0, + pairing: deps.pairing, + jobs: deps.jobs, + health: async () => ({ creatorVersion: '3.8.8', projectName: 'Game', projectOpen: true }), + ...options, + }) + servers.push(server) + const address = server.address() + assert.equal(address.address, '127.0.0.1') + return { ...deps, server, baseUrl: `http://127.0.0.1:${address.port}` } +} + +async function pair(baseUrl, pairing, origin = 'https://windup.example') { + pairing.createCode() + const response = await fetch(`${baseUrl}/v1/pair`, { + method: 'POST', + headers: { Origin: origin, 'Content-Type': 'application/json' }, + body: JSON.stringify({ code: '123456' }), + }) + assert.equal(response.status, 200) + return (await response.json()).token +} + +test('server exposes only protocol and pairing state before the origin is paired', async () => { + const { baseUrl } = await running() + const response = await fetch(`${baseUrl}/v1/health`, { headers: { Origin: 'https://windup.example' } }) + const body = await response.json() + + assert.equal(response.status, 200) + assert.equal(response.headers.get('Access-Control-Allow-Origin'), 'https://windup.example') + assert.deepEqual(body, { protocol: PROTOCOL, paired: false }) + assert.equal(JSON.stringify(body).includes('projectPath'), false) +}) + +test('server exposes project health only to the paired origin', async () => { + const { baseUrl, pairing } = await running() + await pair(baseUrl, pairing) + const response = await fetch(`${baseUrl}/v1/health`, { + headers: { Origin: 'https://windup.example' }, + }) + assert.deepEqual(await response.json(), { + protocol: PROTOCOL, + creatorVersion: '3.8.8', + projectName: 'Game', + projectOpen: true, + paired: true, + }) +}) + +test('server pairs once and accepts an authenticated ZIP import from the exact origin', async () => { + const { baseUrl, pairing, submitted } = await running() + const origin = 'https://windup.example' + const token = await pair(baseUrl, pairing, origin) + const bytes = new TextEncoder().encode('zip') + const response = await fetch(`${baseUrl}/v1/imports`, { + method: 'POST', + headers: { + Origin: origin, + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/zip', + 'X-Windup-Protocol': PROTOCOL, + 'X-Windup-Request-Id': '11111111-1111-4111-8111-111111111111', + 'X-Windup-SHA256': '4a70fe9aa6436e02c2dea340fbd1e352e4ef2d8ce6ca52ad25d4b95471fc8bf2', + }, + body: bytes, + }) + + assert.equal(response.status, 202) + assert.equal(response.headers.get('Access-Control-Allow-Origin'), origin) + assert.deepEqual(await response.json(), { protocol: PROTOCOL, jobId: 'job-1' }) + assert.equal(submitted.length, 1) + assert.equal(new TextDecoder().decode(submitted[0].zipBytes), 'zip') +}) + +test('server rejects wrong origins and missing bearer tokens', async () => { + const { baseUrl, pairing } = await running() + const token = await pair(baseUrl, pairing) + const headers = { + Origin: 'https://evil.example', + Authorization: `Bearer ${token}`, + 'X-Windup-Protocol': PROTOCOL, + } + const wrongOrigin = await fetch(`${baseUrl}/v1/imports/job-1`, { headers }) + assert.equal(wrongOrigin.status, 403) + + const missingToken = await fetch(`${baseUrl}/v1/imports/job-1`, { + headers: { Origin: 'https://windup.example', 'X-Windup-Protocol': PROTOCOL }, + }) + assert.equal(missingToken.status, 401) +}) + +test('server handles an authorized CORS preflight with the fixed header allowlist', async () => { + const { baseUrl, pairing } = await running() + await pair(baseUrl, pairing) + const response = await fetch(`${baseUrl}/v1/imports`, { + method: 'OPTIONS', + headers: { + Origin: 'https://windup.example', + 'Access-Control-Request-Method': 'POST', + }, + }) + + assert.equal(response.status, 204) + assert.equal(response.headers.get('Access-Control-Allow-Origin'), 'https://windup.example') + assert.match(response.headers.get('Access-Control-Allow-Headers'), /Authorization/) +}) + +test('server handles pairing and job polling preflights', async () => { + const { baseUrl, pairing } = await running() + const origin = 'https://windup.example' + const pairingPreflight = await fetch(`${baseUrl}/v1/pair`, { + method: 'OPTIONS', + headers: { Origin: origin, 'Access-Control-Request-Method': 'POST' }, + }) + assert.equal(pairingPreflight.status, 204) + assert.equal(pairingPreflight.headers.get('Access-Control-Allow-Origin'), origin) + assert.match(pairingPreflight.headers.get('Access-Control-Allow-Methods'), /POST/) + assert.match(pairingPreflight.headers.get('Access-Control-Allow-Headers'), /Content-Type/) + + await pair(baseUrl, pairing, origin) + const pollingPreflight = await fetch(`${baseUrl}/v1/imports/job-1`, { + method: 'OPTIONS', + headers: { Origin: origin, 'Access-Control-Request-Method': 'GET' }, + }) + assert.equal(pollingPreflight.status, 204) + assert.equal(pollingPreflight.headers.get('Access-Control-Allow-Origin'), origin) + assert.match(pollingPreflight.headers.get('Access-Control-Allow-Methods'), /GET/) + assert.match(pollingPreflight.headers.get('Access-Control-Allow-Headers'), /Authorization/) +}) + +test('server rejects non-loopback binding and oversized uploads', async () => { + await assert.rejects( + () => running({ host: '0.0.0.0' }), + /BRIDGE_HOST_FORBIDDEN/, + ) + const { baseUrl, pairing, submitted } = await running({ maxUploadBytes: 2 }) + const token = await pair(baseUrl, pairing) + const response = await fetch(`${baseUrl}/v1/imports`, { + method: 'POST', + headers: { + Origin: 'https://windup.example', + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/zip', + 'X-Windup-Protocol': PROTOCOL, + 'X-Windup-Request-Id': '11111111-1111-4111-8111-111111111111', + 'X-Windup-SHA256': '4a70fe9aa6436e02c2dea340fbd1e352e4ef2d8ce6ca52ad25d4b95471fc8bf2', + }, + body: 'zip', + }) + assert.equal(response.status, 413) + assert.equal(submitted.length, 0) +}) + +test('server rejects a 36-character request id that is not a UUID', async () => { + const { baseUrl, pairing, submitted } = await running() + const token = await pair(baseUrl, pairing) + const bytes = new TextEncoder().encode('zip') + const response = await fetch(`${baseUrl}/v1/imports`, { + method: 'POST', + headers: { + Origin: 'https://windup.example', + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/zip', + 'X-Windup-Protocol': PROTOCOL, + 'X-Windup-Request-Id': '------------------------------------', + 'X-Windup-SHA256': '4a70fe9aa6436e02c2dea340fbd1e352e4ef2d8ce6ca52ad25d4b95471fc8bf2', + }, + body: bytes, + }) + + assert.equal(response.status, 400) + assert.equal(submitted.length, 0) +}) + +test('server returns 426 for incompatible protocol versions', async () => { + const { baseUrl, pairing } = await running() + const token = await pair(baseUrl, pairing) + const response = await fetch(`${baseUrl}/v1/imports/job-1`, { + headers: { + Origin: 'https://windup.example', + Authorization: `Bearer ${token}`, + 'X-Windup-Protocol': 'windup-cocos-bridge/2.0.0', + }, + }) + assert.equal(response.status, 426) +}) diff --git a/tools/cocos-importer/extension/test/import-job.test.mjs b/tools/cocos-importer/extension/test/import-job.test.mjs new file mode 100644 index 00000000..25156920 --- /dev/null +++ b/tools/cocos-importer/extension/test/import-job.test.mjs @@ -0,0 +1,180 @@ +import { createHash } from 'node:crypto' +import { mkdtemp, mkdir, readFile, rm, symlink, writeFile } from 'node:fs/promises' +import { existsSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { afterEach, test } from 'node:test' +import assert from 'node:assert/strict' + +import { ImportJobRunner } from '../source/import-job.js' + +const roots = [] + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +async function fixture(overrides = {}) { + const projectPath = await mkdtemp(join(tmpdir(), 'windup-cocos-job-')) + roots.push(projectPath) + const calls = [] + const assets = { + async refresh(dbUrl) { + calls.push(['refresh', dbUrl]) + }, + async query(dbUrl) { + calls.push(['query', dbUrl]) + return { uuid: `uuid-${calls.length}`, url: dbUrl } + }, + async reveal(dbUrl) { + calls.push(['reveal', dbUrl]) + }, + ...overrides.assets, + } + const prepared = { + packFolder: 'windup-imports/Hero/Ranger', + files: new Map([ + ['windup-imports/Hero/Ranger/animations/Walk.anim', new TextEncoder().encode('animation')], + ['windup-imports/Hero/Ranger/prefabs/Hero-Ranger.prefab', new TextEncoder().encode('prefab')], + ]), + plan: { + animations: [{ name: 'Walk' }], + prefab: { cocosPath: 'windup-imports/Hero/Ranger/prefabs/Hero-Ranger.prefab' }, + }, + summary: { animationCount: 1, frameCount: 3 }, + ...overrides.prepared, + } + let prepares = 0 + const runner = new ImportJobRunner({ + projectPath, + assets, + fileSystem: overrides.fileSystem, + prepareImport: () => { + prepares += 1 + if (overrides.prepareError) throw overrides.prepareError + return prepared + }, + }) + return { projectPath, calls, runner, prepared, prepares: () => prepares } +} + +function request(bytes = new TextEncoder().encode('zip')) { + return { + requestId: '11111111-1111-4111-8111-111111111111', + zipBytes: bytes, + sha256: createHash('sha256').update(bytes).digest('hex'), + } +} + +test('ImportJobRunner writes, refreshes, verifies and reveals a new import', async () => { + const { projectPath, calls, runner } = await fixture() + const phases = [] + const result = await runner.run({ ...request(), onPhase: (phase) => phases.push(phase) }) + + assert.equal( + await readFile(join(projectPath, 'assets/windup-imports/Hero/Ranger/prefabs/Hero-Ranger.prefab'), 'utf8'), + 'prefab', + ) + assert.deepEqual(phases, ['converting', 'writing', 'refreshing', 'verifying']) + assert.equal(result.dbUrl, 'db://assets/windup-imports/Hero/Ranger/prefabs/Hero-Ranger.prefab') + assert.equal(result.animationCount, 1) + assert.deepEqual(calls.at(-1), ['reveal', result.dbUrl]) + assert.equal(existsSync(join(projectPath, 'temp/windup-importer')), false) +}) + +test('ImportJobRunner returns the same promise for a duplicate request id', async () => { + const context = await fixture() + const first = context.runner.run(request()) + const second = context.runner.run(request()) + assert.equal(first, second) + assert.deepEqual(await second, await first) + assert.equal(context.prepares(), 1) +}) + +test('ImportJobRunner rejects digest mismatch before conversion or disk writes', async () => { + const context = await fixture() + await assert.rejects( + () => context.runner.run({ ...request(), sha256: '0'.repeat(64) }), + /IMPORT_SHA256_MISMATCH/, + ) + assert.equal(context.prepares(), 0) + assert.equal(existsSync(join(context.projectPath, 'assets')), false) +}) + +test('ImportJobRunner rejects a prepared path outside assets/windup-imports', async () => { + const context = await fixture({ + prepared: { + packFolder: '../escape', + files: new Map([['../escape/p.prefab', new TextEncoder().encode('bad')]]), + plan: { animations: [], prefab: { cocosPath: '../escape/p.prefab' } }, + }, + }) + await assert.rejects(() => context.runner.run(request()), /IMPORT_PATH_FORBIDDEN/) + assert.equal(existsSync(join(context.projectPath, 'escape')), false) +}) + +test('ImportJobRunner restores an existing pack when AssetDB refresh fails', async () => { + let refreshes = 0 + const context = await fixture({ + assets: { + async refresh() { + refreshes += 1 + if (refreshes === 1) throw new Error('refresh failed') + }, + }, + }) + const oldPrefab = join(context.projectPath, 'assets/windup-imports/Hero/Ranger/prefabs/Hero-Ranger.prefab') + await mkdir(join(oldPrefab, '..'), { recursive: true }) + await writeFile(oldPrefab, 'original prefab') + + await assert.rejects( + () => context.runner.run(request()), + (error) => error.code === 'IMPORT_FAILED' && error.rolledBack === true, + ) + assert.equal(await readFile(oldPrefab, 'utf8'), 'original prefab') + assert.equal(refreshes, 2) + assert.equal(existsSync(join(context.projectPath, 'temp/windup-importer')), false) +}) + +test('ImportJobRunner removes a new pack when verification fails', async () => { + const context = await fixture({ assets: { query: async () => null } }) + await assert.rejects( + () => context.runner.run(request()), + (error) => error.code === 'IMPORT_VERIFY_FAILED' && error.rolledBack === true, + ) + assert.equal(existsSync(join(context.projectPath, 'assets/windup-imports/Hero/Ranger')), false) + assert.equal(existsSync(join(context.projectPath, 'temp/windup-importer')), false) +}) + +test('ImportJobRunner reports rollback failure without skipping transaction cleanup', async () => { + const context = await fixture({ + assets: { query: async () => null }, + fileSystem: { + async rm(path, options) { + if (path.endsWith(join('Hero', 'Ranger'))) throw new Error('private disk path') + return rm(path, options) + }, + }, + }) + + await assert.rejects( + () => context.runner.run(request()), + (error) => error.code === 'IMPORT_ROLLBACK_FAILED' && error.rolledBack === false, + ) + assert.equal(existsSync(join(context.projectPath, 'temp/windup-importer')), false) +}) + +test('ImportJobRunner rejects a symlinked import root before writing outside the project', async () => { + const context = await fixture() + const outside = await mkdtemp(join(tmpdir(), 'windup-cocos-outside-')) + roots.push(outside) + await mkdir(join(context.projectPath, 'assets'), { recursive: true }) + await symlink( + outside, + join(context.projectPath, 'assets/windup-imports'), + process.platform === 'win32' ? 'junction' : 'dir', + ) + + await assert.rejects(() => context.runner.run(request()), /IMPORT_PATH_SYMLINK/) + assert.equal(existsSync(join(outside, 'Hero/Ranger')), false) +}) diff --git a/tools/cocos-importer/extension/test/import-jobs.test.mjs b/tools/cocos-importer/extension/test/import-jobs.test.mjs new file mode 100644 index 00000000..2c0ceaf2 --- /dev/null +++ b/tools/cocos-importer/extension/test/import-jobs.test.mjs @@ -0,0 +1,167 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' + +import { createImportJobs } from '../source/import-jobs.js' +import { PROTOCOL } from '../source/protocol.js' + +function deferred() { + let resolve + let reject + const promise = new Promise((yes, no) => { + resolve = yes + reject = no + }) + return { promise, resolve, reject } +} + +async function nextTurn() { + await new Promise((resolve) => setImmediate(resolve)) +} + +test('import jobs expose live phases and a completed protocol result', async () => { + const run = deferred() + let request + const jobs = createImportJobs({ + runner: { + run(value) { + request = value + return run.promise + }, + }, + projectName: () => 'Game', + randomUUID: () => 'job-1', + }) + + assert.deepEqual(await jobs.submit({ requestId: 'request-1', zipBytes: new Uint8Array([1]), sha256: 'abc' }), { + jobId: 'job-1', + }) + request.onPhase('writing') + assert.deepEqual(await jobs.get('job-1'), { + protocol: PROTOCOL, + jobId: 'job-1', + status: 'running', + phase: 'writing', + }) + + run.resolve({ dbUrl: 'db://assets/p.prefab', animationCount: 2, frameCount: 64 }) + await nextTurn() + assert.deepEqual(await jobs.get('job-1'), { + protocol: PROTOCOL, + jobId: 'job-1', + status: 'completed', + phase: 'verifying', + result: { + projectName: 'Game', + dbUrl: 'db://assets/p.prefab', + animationCount: 2, + frameCount: 64, + }, + }) +}) + +test('import jobs reuse a job and expose a safe runner-provided failure', async () => { + const run = deferred() + const jobs = createImportJobs({ + runner: { + run(request) { + request.onPhase('verifying') + return run.promise + }, + }, + projectName: () => 'Game', + randomUUID: () => 'job-1', + }) + const submitted = await jobs.submit({ requestId: 'request-1', zipBytes: new Uint8Array(), sha256: 'abc' }) + assert.deepEqual(await jobs.submit({ requestId: 'request-1', zipBytes: new Uint8Array(), sha256: 'abc' }), submitted) + + const failure = new Error('internal detail must not leak') + failure.code = 'IMPORT_VERIFY_FAILED' + failure.rolledBack = true + run.reject(failure) + await nextTurn() + assert.deepEqual(await jobs.get('job-1'), { + protocol: PROTOCOL, + jobId: 'job-1', + status: 'failed', + phase: 'verifying', + error: { code: 'IMPORT_VERIFY_FAILED', message: '导入结果校验失败', rolledBack: true }, + }) + assert.equal(await jobs.get('missing'), null) +}) + +test('import jobs redact arbitrary filesystem errors', async () => { + const jobs = createImportJobs({ + runner: { run: async () => { throw new Error('ENOENT: C:\\Users\\private\\secret.png') } }, + projectName: () => 'Game', + randomUUID: () => 'job-1', + }) + await jobs.submit({ requestId: 'request-1', zipBytes: new Uint8Array(), sha256: 'abc' }) + await nextTurn() + const job = await jobs.get('job-1') + assert.deepEqual(job.error, { code: 'IMPORT_FAILED', message: 'Cocos 导入失败', rolledBack: false }) + assert.equal(JSON.stringify(job).includes('C:\\Users'), false) +}) + +test('import jobs evict completed history at the configured bound', async () => { + let nextId = 0 + const jobs = createImportJobs({ + runner: { run: async () => ({ dbUrl: 'db://assets/p.prefab', animationCount: 1, frameCount: 1 }) }, + projectName: () => 'Game', + randomUUID: () => `job-${++nextId}`, + maxJobs: 2, + }) + for (let index = 1; index <= 3; index += 1) { + await jobs.submit({ requestId: `request-${index}`, zipBytes: new Uint8Array(), sha256: `${index}` }) + await nextTurn() + } + assert.equal(await jobs.get('job-1'), null) + assert.equal((await jobs.get('job-3')).status, 'completed') +}) + +test('import jobs reject a new request when every bounded slot is running', async () => { + const run = deferred() + const jobs = createImportJobs({ + runner: { run: () => run.promise }, + projectName: () => 'Game', + randomUUID: () => 'job-1', + maxJobs: 1, + }) + await jobs.submit({ requestId: 'request-1', zipBytes: new Uint8Array(), sha256: 'abc' }) + await assert.rejects( + () => jobs.submit({ requestId: 'request-2', zipBytes: new Uint8Array(), sha256: 'def' }), + /IMPORT_QUEUE_FULL/, + ) + run.resolve({ dbUrl: 'db://assets/p.prefab', animationCount: 1, frameCount: 1 }) +}) + +test('closing jobs aborts running imports and rejects later submissions', async () => { + const run = deferred() + let signal + const jobs = createImportJobs({ + runner: { + run(request) { + signal = request.signal + return run.promise + }, + }, + projectName: () => 'Game', + randomUUID: () => 'job-1', + }) + await jobs.submit({ requestId: 'request-1', zipBytes: new Uint8Array(), sha256: 'abc' }) + jobs.close() + assert.equal(signal.aborted, true) + await assert.rejects( + () => jobs.submit({ requestId: 'request-2', zipBytes: new Uint8Array(), sha256: 'def' }), + /IMPORT_SERVICE_CLOSED/, + ) + const aborted = new Error('internal abort detail') + aborted.code = 'IMPORT_ABORTED' + aborted.rolledBack = true + run.reject(aborted) + await nextTurn() + assert.deepEqual((await jobs.get('job-1')).error, { + code: 'IMPORT_ABORTED', + message: '导入已取消', + rolledBack: true, + }) +}) diff --git a/tools/cocos-importer/extension/test/main.test.mjs b/tools/cocos-importer/extension/test/main.test.mjs new file mode 100644 index 00000000..7fd3c34d --- /dev/null +++ b/tools/cocos-importer/extension/test/main.test.mjs @@ -0,0 +1,67 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' + +import { createExtension } from '../source/main.js' + +function editor() { + const profile = new Map() + const dialogs = [] + return { + App: { version: '3.8.8' }, + Project: { name: 'Game', path: 'D:/Game' }, + Message: { + request: async () => null, + send: () => {}, + }, + Profile: { + async getConfig(packageName, key, scope) { + return profile.get(`${packageName}:${key}:${scope}`) + }, + async setConfig(packageName, key, value, scope) { + profile.set(`${packageName}:${key}:${scope}`, value) + }, + }, + Dialog: { + async info(message, options) { + dialogs.push({ message, options }) + }, + }, + dialogs, + } +} + +test('extension starts the fixed loopback service and closes it on unload', async () => { + const Editor = editor() + const calls = [] + const extension = createExtension({ + Editor, + serverFactory: async (options) => { + calls.push(options) + return { close: async () => calls.push('closed') } + }, + }) + + await extension.load() + assert.equal(calls[0].host, '127.0.0.1') + assert.equal(calls[0].port, 17_832) + assert.deepEqual(await calls[0].health(), { + creatorVersion: '3.8.8', + projectName: 'Game', + projectOpen: true, + }) + await extension.unload() + assert.equal(calls[1], 'closed') +}) + +test('extension menu exposes pairing code and persisted connection status', async () => { + const Editor = editor() + const extension = createExtension({ Editor, serverFactory: async () => ({ close: async () => {} }) }) + + const code = await extension.showPairingCode() + assert.match(code, /^\d{6}$/) + assert.match(Editor.dialogs[0].message, new RegExp(code)) + await extension.pairing.pair(code, 'https://windup.example') + const status = await extension.showConnectionStatus() + assert.match(status, /https:\/\/windup\.example/) + assert.equal(Editor.dialogs.length, 2) +}) diff --git a/tools/cocos-importer/extension/test/pairing-store.test.mjs b/tools/cocos-importer/extension/test/pairing-store.test.mjs new file mode 100644 index 00000000..4aa52aca --- /dev/null +++ b/tools/cocos-importer/extension/test/pairing-store.test.mjs @@ -0,0 +1,73 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' + +import { PairingStore } from '../source/pairing-store.js' + +class MemoryProfile { + value = null + + async load() { + return this.value + } + + async save(value) { + this.value = value + } +} + +function store({ now = 1_000, profile = new MemoryProfile() } = {}) { + let clock = now + const instance = new PairingStore({ + profile, + now: () => clock, + randomCode: () => '123456', + randomToken: () => 'a'.repeat(64), + }) + return { instance, profile, advance: (milliseconds) => (clock += milliseconds) } +} + +test('PairingStore issues a six-digit code valid for five minutes', () => { + const { instance, advance } = store() + assert.equal(instance.createCode(), '123456') + assert.equal(instance.hasActiveCode(), true) + advance(5 * 60_000 + 1) + assert.equal(instance.hasActiveCode(), false) +}) + +test('PairingStore stores only token digest and authorizes exact origin', async () => { + const { instance, profile } = store() + instance.createCode() + const token = await instance.pair('123456', 'https://windup.example') + + assert.equal(token, 'a'.repeat(64)) + assert.equal(profile.value.origin, 'https://windup.example') + assert.notEqual(profile.value.tokenDigest, token) + assert.equal(profile.value.tokenDigest.length, 64) + assert.equal(await instance.authorize('https://windup.example', token), true) + assert.equal(await instance.authorize('https://evil.example', token), false) + assert.equal(await instance.authorize('https://windup.example', 'b'.repeat(64)), false) +}) + +test('PairingStore invalidates a code after five wrong attempts', async () => { + const { instance } = store() + instance.createCode() + for (let attempt = 0; attempt < 4; attempt += 1) { + await assert.rejects(() => instance.pair('000000', 'https://windup.example'), /PAIR_CODE_INVALID/) + } + await assert.rejects(() => instance.pair('000000', 'https://windup.example'), /PAIR_CODE_LOCKED/) + await assert.rejects(() => instance.pair('123456', 'https://windup.example'), /PAIR_CODE_EXPIRED/) +}) + +test('PairingStore consumes a successful code and restores persisted pairing', async () => { + const first = store() + first.instance.createCode() + await first.instance.pair('123456', 'https://windup.example') + await assert.rejects( + () => first.instance.pair('123456', 'https://windup.example'), + /PAIR_CODE_EXPIRED/, + ) + + const restored = store({ profile: first.profile }).instance + assert.equal(await restored.isOriginAllowed('https://windup.example'), true) + assert.equal(await restored.isOriginAllowed('https://other.example'), false) +}) diff --git a/tools/cocos-importer/package.json b/tools/cocos-importer/package.json new file mode 100644 index 00000000..173845cd --- /dev/null +++ b/tools/cocos-importer/package.json @@ -0,0 +1,15 @@ +{ + "name": "windup-importer", + "version": "0.1.0", + "description": "把 Windup 一键导出的 Cocos Creator 适配包自动导入到 Cocos Creator 工程。", + "author": "xyh202131", + "license": "Apache-2.0", + "type": "module", + "scripts": { + "cli": "node ./bin/windup-cocos-import.mjs", + "test": "node --test test/manifest-reader.test.mjs test/asset-planner.test.mjs test/bridge-uuid.test.mjs test/import-core.test.mjs test/e2e-cli.test.mjs" + }, + "engines": { + "node": ">=20" + } +} diff --git a/tools/cocos-importer/src/asset-planner.js b/tools/cocos-importer/src/asset-planner.js new file mode 100644 index 00000000..5b9100aa --- /dev/null +++ b/tools/cocos-importer/src/asset-planner.js @@ -0,0 +1,158 @@ +// 决定把 Windup 适配包里的哪些文件落到 Cocos 工程的哪个位置, +// 产出 SpriteFrame / AnimationClip / Prefab 三类资产的元数据。 +// 输出是计划,真正落盘交给 adapter(Node CLI 或 Cocos 扩展)。 + +/** + * @typedef {import('./manifest-reader.js').WindupCocosManifest} WindupCocosManifest + */ + +/** + * @typedef {{ + * packFolder: string, // 例如 'windup-imports/Hero/Ranger' + * spriteFrames: Array<{ + * sourcePath: string, // ZIP 内相对路径,例如 'character/master.png' + * cocosPath: string, // 目标 Cocos 资产路径 + * rect: { x: number, y: number, w: number, h: number }, + * trim: { x: number, y: number, w: number, h: number }, + * }>, + * animations: Array<{ + * id: string, + * name: string, + * direction: string, + * fps: number, + * loop: boolean, + * duration: number, // 秒 + * frames: Array<{ spriteFramePath: string, index: number, time: number, duration: number }>, + * }>, + * prefab: { + * name: string, + * cocosPath: string, + * nodeName: string, + * anchor: { x: number, y: number }, + * footY: number, + * canvas: { w: number, h: number }, + * }, + * }} ImportPlan + */ + +/** + * @param {WindupCocosManifest} manifest + * @returns {ImportPlan} + */ +export function planImport(manifest) { + const characterSlug = safeSegment(manifest.package.character_name, 'character') + const outfitSlug = safeSegment(manifest.package.outfit_name, 'outfit') + const packFolder = `windup-imports/${characterSlug}/${outfitSlug}` + + const spriteFrames = [] + const animations = [] + const usedNames = new Set() + + // master → 单张 SpriteFrame + const masterSlug = safeSegment(`${characterSlug}-master`, 'master') + spriteFrames.push({ + sourcePath: manifest.master.file, + cocosPath: `${packFolder}/textures/${masterSlug}.png`, + rect: { x: 0, y: 0, w: manifest.package.canvas.w, h: manifest.package.canvas.h }, + trim: { x: 0, y: 0, w: manifest.package.canvas.w, h: manifest.package.canvas.h }, + }) + + for (const action of manifest.actions) { + const actionSlug = safeSegment(action.export_name, 'action') + const uniqueName = actionSlug + if (usedNames.has(uniqueName)) { + throw new Error(`动作名重复: ${uniqueName}(同一适配包内 export_name 必须唯一)`) + } + usedNames.add(uniqueName) + + const cellW = action.atlas.cell.w + const cellH = action.atlas.cell.h + const cols = action.atlas.cols + const legacyTiming = action.timing_mode === undefined + const fallbackSeconds = legacyTiming ? Math.round(1000 / action.fps) / 1000 : 1 / action.fps + const frameDurations = action.frames.map((frame) => + typeof frame.duration_ms === 'number' && frame.duration_ms > 0 + ? frame.duration_ms / 1000 + : fallbackSeconds, + ) + const frameTimes = [] + let elapsed = 0 + for (let index = 0; index < action.frames.length; index += 1) { + frameTimes.push(action.timing_mode === 'constant-fps' ? index / action.fps : elapsed) + elapsed += frameDurations[index] ?? 0 + } + + // 每张 frame → SpriteFrame(atlas 子区域) + // 注意:manifest 里 frame.file 是 basename(Walk_000.png),但 ZIP 里 + // 实际路径是 frames//。需要拼完整路径去找源图。 + const spriteFramePaths = [] + action.frames.forEach((frame) => { + const spriteSlug = `${uniqueName}_${String(frame.index).padStart(3, '0')}` + const sfPath = `${packFolder}/animations/${uniqueName}/${spriteSlug}.png` + spriteFramePaths.push(sfPath) + spriteFrames.push({ + sourcePath: `frames/${action.export_name}/${frame.file}`, + cocosPath: sfPath, + // 这里复制的是单帧 PNG,不是 atlas;每张单帧纹理的 SpriteFrame + // 裁剪原点都必须是(0,0),否则后续帧会被按 atlas 偏移裁成空图。 + rect: { x: 0, y: 0, w: cellW, h: cellH }, + trim: { x: 0, y: 0, w: cellW, h: cellH }, + }) + }) + + // 整张 atlas → 单张图集纹理(Windup 输出是一张大图,Cocos SpriteAtlas 需要它) + const atlasSlug = uniqueName + spriteFrames.push({ + sourcePath: action.atlas.file, + cocosPath: `${packFolder}/animations/${atlasSlug}/atlas.png`, + rect: { x: 0, y: 0, w: cellW * cols, h: cellH * Math.ceil(action.frames.length / cols) }, + trim: { x: 0, y: 0, w: cellW * cols, h: cellH * Math.ceil(action.frames.length / cols) }, + }) + + const durationSec = action.timing_mode === 'constant-fps' + ? action.frames.length / action.fps + : frameDurations.reduce((sum, duration) => sum + duration, 0) + animations.push({ + id: action.id, + name: uniqueName, + direction: action.direction, + fps: action.fps, + loop: action.loop, + duration: durationSec, + frames: action.frames.map((frame, idx) => ({ + spriteFramePath: spriteFramePaths[idx], + index: frame.index, + time: frameTimes[idx] ?? 0, + duration: frameDurations[idx] ?? 0, + })), + }) + } + + // prefab:节点带 Sprite(主母版) + Animation 组件(指向第一个动作) + const prefabName = `${characterSlug}-${outfitSlug}` + const firstAction = animations[0] + return { + packFolder, + spriteFrames, + animations, + prefab: { + name: prefabName, + cocosPath: `${packFolder}/prefabs/${prefabName}.prefab`, + nodeName: prefabName, + anchor: manifest.master.anchor_cocos, + footY: manifest.master.foot_y ?? 0, + canvas: manifest.package.canvas, + defaultAnimation: firstAction ? firstAction.name : null, + }, + } +} + +/** + * @param {string} value + * @param {string} fallback + * @returns {string} + */ +function safeSegment(value, fallback) { + const normalized = value.normalize('NFKC').replace(/[^\p{L}\p{N}]+/gu, '-').replace(/^-+|-+$/g, '') + return normalized || fallback +} diff --git a/tools/cocos-importer/src/cocos-bridge.js b/tools/cocos-importer/src/cocos-bridge.js new file mode 100644 index 00000000..c92d098c --- /dev/null +++ b/tools/cocos-importer/src/cocos-bridge.js @@ -0,0 +1,414 @@ +// 把"计划 + 字节内容"翻译成 Cocos Creator 真正能识别的 .meta / .prefab / .anim 文件。 +// 关键:每个资产分配稳定的 RFC 4122 UUID,Cocos 打开时会保留 .meta 里的 uuid +// 并让 prefab/anim 里的 __uuid__ 引用对得上。Cocos Creator 3.x 会把非 RFC +// 格式的短 ID 重写成新的 UUID,导致导出的引用在编辑器里变成未解析占位符。 + +/** + * @typedef {import('./asset-planner.js').ImportPlan} ImportPlan + */ + +import { createHash } from 'node:crypto' + +/** + * 路径 → RFC 4122 UUID v5(确定性,跨机器一致)。 + * @param {string} path + * @returns {string} + */ +export function uuidForPath(path) { + const hex = createHash('sha1').update(`windup-cocos-importer:${path}`).digest('hex').slice(0, 32).split('') + // Mark the deterministic SHA-1-derived value as UUID v5 and set the RFC + // 4122 variant. Creator accepts and preserves this canonical form. + hex[12] = '5' + hex[16] = (Number.parseInt(hex[16], 16) & 0x3 | 0x8).toString(16) + return `${hex.slice(0, 8).join('')}-${hex.slice(8, 12).join('')}-${hex.slice(12, 16).join('')}-${hex.slice(16, 20).join('')}-${hex.slice(20).join('')}` +} + +/** + * @param {WindupCocosManifest} manifest + * @param {ImportPlan} plan + * @param {string} packName + * @returns {Record} filename → JSON 文本 + */ +export function buildCocosMetaFiles(manifest, plan, packName) { + const files = {} + + // 给每个 PNG 分配纹理 UUID 和 SpriteFrame 子资源 UUID。 + // Cocos Creator 的 AnimationClip / Prefab 引用的是子资源 UUID,不是 + // PNG 顶层纹理 UUID;两者都写进 .meta 才能保持 Creator 的引用语义。 + const spriteFrameUuids = new Map() + for (const sf of plan.spriteFrames) { + const textureUuid = uuidForPath(sf.cocosPath) + // Cocos Creator 3.x reserves these sub-asset ids for image imports. A + // random/short child UUID is accepted by the CLI but is not materialized + // into library metadata, leaving Sprite/Animation references unresolved. + const textureSubUuid = `${textureUuid}@6c48a` + const spriteFrameUuid = `${textureUuid}@f9941` + const displayName = sf.cocosPath.split('/').pop()?.replace(/\.png$/i, '') || 'sprite' + const rawWidth = sf.rect.w + const rawHeight = sf.rect.h + const trim = sf.trim || sf.rect + const trimX = trim.x ?? 0 + const trimY = trim.y ?? 0 + const trimWidth = trim.w ?? rawWidth + const trimHeight = trim.h ?? rawHeight + const halfWidth = trimWidth / 2 + const halfHeight = trimHeight / 2 + spriteFrameUuids.set(sf.cocosPath, spriteFrameUuid) + const metaPath = `${sf.cocosPath}.meta` + files[metaPath] = JSON.stringify( + { + ver: '1.0.27', + importer: 'image', + imported: true, + uuid: textureUuid, + files: ['.json', '.png'], + subMetas: { + '6c48a': { + importer: 'texture', + uuid: textureSubUuid, + displayName, + id: '6c48a', + name: 'texture', + userData: { + wrapModeS: 'clamp-to-edge', + wrapModeT: 'clamp-to-edge', + imageUuidOrDatabaseUri: textureUuid, + isUuid: true, + visible: false, + minfilter: 'linear', + magfilter: 'linear', + mipfilter: 'none', + anisotropy: 0, + }, + ver: '1.0.22', + imported: true, + files: ['.json'], + subMetas: {}, + }, + f9941: { + importer: 'sprite-frame', + uuid: spriteFrameUuid, + displayName, + id: 'f9941', + name: 'spriteFrame', + userData: { + trimThreshold: 1, + rotated: false, + offsetX: 0, + offsetY: 0, + trimX, + trimY, + width: trimWidth, + height: trimHeight, + rawWidth, + rawHeight, + borderTop: 0, + borderBottom: 0, + borderLeft: 0, + borderRight: 0, + packable: true, + pixelsToUnit: 100, + pivotX: 0.5, + pivotY: 0.5, + meshType: 0, + vertices: { + rawPosition: [ + -halfWidth, + -halfHeight, + 0, + halfWidth, + -halfHeight, + 0, + -halfWidth, + halfHeight, + 0, + halfWidth, + halfHeight, + 0, + ], + indexes: [0, 1, 2, 2, 1, 3], + uv: [0, rawHeight, rawWidth, rawHeight, 0, 0, rawWidth, 0], + nuv: [0, 0, 1, 0, 0, 1, 1, 1], + minPos: [-halfWidth, -halfHeight, 0], + maxPos: [halfWidth, halfHeight, 0], + }, + isUuid: true, + imageUuidOrDatabaseUri: textureSubUuid, + atlasUuid: '', + trimType: 'auto', + }, + ver: '1.0.12', + imported: true, + files: ['.json'], + subMetas: {}, + }, + }, + userData: { + type: 'sprite-frame', + fixAlphaTransparencyArtifacts: false, + hasAlpha: true, + // This is the redirect emitted by Creator for image assets; the + // actual SpriteFrame ref above remains the @f9941 child asset. + redirect: textureSubUuid, + }, + }, + null, + 2, + ) + } + + // 每个 AnimationClip:写真正的 .anim(Cocos 3.x AnimationClip JSON)+ .anim.meta + for (const anim of plan.animations) { + const animName = `${anim.name}.anim` + const animPath = `${plan.packFolder}/animations/${animName}` + const animUuid = uuidForPath(animPath) + let frameTime = 0 + const times = anim.frames.map((frame) => { + const time = Number.isFinite(frame.time) ? frame.time : frameTime + frameTime += frame.duration + return time + }) + const values = anim.frames.map((frame) => ({ + __uuid__: spriteFrameUuids.get(frame.spriteFramePath) || `frame:${frame.spriteFramePath}`, + __expectedType__: 'cc.SpriteFrame', + })) + files[animPath] = JSON.stringify( + { + __type__: 'cc.AnimationClip', + _name: anim.name, + _objFlags: 0, + __editorExtras__: {}, + _native: '', + sample: anim.fps, + speed: 1, + wrapMode: anim.loop ? 2 : 1, + enableTrsBlending: false, + _duration: anim.duration, + _hash: 0, + _tracks: [ + { + __type__: 'cc.animation.ObjectTrack', + _binding: { + __type__: 'cc.animation.TrackBinding', + path: { + __type__: 'cc.animation.TrackPath', + _paths: [ + { __type__: 'cc.animation.ComponentPath', component: 'cc.Sprite' }, + 'spriteFrame', + ], + }, + }, + _channel: { + __type__: 'cc.animation.Channel', + _curve: { + __type__: 'cc.ObjectCurve', + _times: times, + _values: values, + }, + }, + }, + ], + _exoticAnimation: null, + _events: [], + _embeddedPlayers: [], + _additiveSettings: { + __type__: 'cc.AnimationClipAdditiveSettings', + enabled: false, + refClip: null, + }, + _auxiliaryCurveEntries: [], + _windupDirection: anim.direction, + }, + null, + 2, + ) + files[`${animPath}.meta`] = JSON.stringify( + { + ver: '1.0.0', + uuid: animUuid, + subMetas: {}, + _windupDirection: anim.direction, + }, + null, + 2, + ) + } + + // 主 Prefab + const prefabUuid = uuidForPath(plan.prefab.cocosPath) + const masterSlug = safeSegment(`${manifest.package.character_name}-master`, 'master') + const masterCocosPath = `${plan.packFolder}/textures/${masterSlug}.png` + const masterUuid = spriteFrameUuids.get(masterCocosPath) + files[`${plan.prefab.cocosPath}.meta`] = JSON.stringify( + { + ver: '1.0.0', + uuid: prefabUuid, + asyncLoadAssets: false, + autoReleaseAssets: false, + subMetas: {}, + }, + null, + 2, + ) + // Cocos Creator 3.x prefab files are serialized object arrays. The first + // object is the prefab asset and `data` points at the root Node by numeric + // id; a hand-written object tree makes the editor importer fail while it + // resolves Node/Component references. + const animationRefs = plan.animations.map((a) => ({ + __uuid__: uuidForPath(`${plan.packFolder}/animations/${a.name}.anim`), + })) + const firstAnimationRef = animationRefs[0] || null + const prefabInfoId = 8 + const fileId = prefabFileId(`${plan.prefab.cocosPath}#prefab-info`) + const uiFileId = prefabFileId(`${plan.prefab.cocosPath}#ui-transform`) + const spriteFileId = prefabFileId(`${plan.prefab.cocosPath}#sprite`) + const animationFileId = prefabFileId(`${plan.prefab.cocosPath}#animation`) + files[plan.prefab.cocosPath] = JSON.stringify( + [ + { + __type__: 'cc.Prefab', + _name: plan.prefab.nodeName, + _objFlags: 0, + _native: '', + data: { __id__: 1 }, + optimizationPolicy: 0, + asyncLoadAssets: false, + persistent: false, + _windupPack: packName, + }, + { + __type__: 'cc.Node', + _name: plan.prefab.nodeName, + _objFlags: 0, + _parent: null, + _children: [], + _active: true, + _components: [{ __id__: 2 }, { __id__: 4 }, { __id__: 6 }], + _prefab: { __id__: prefabInfoId }, + _lpos: { __type__: 'cc.Vec3', x: 0, y: 0, z: 0 }, + _lrot: { __type__: 'cc.Quat', x: 0, y: 0, z: 0, w: 1 }, + _lscale: { __type__: 'cc.Vec3', x: 1, y: 1, z: 1 }, + _layer: 33554432, + _euler: { __type__: 'cc.Vec3', x: 0, y: 0, z: 0 }, + _id: '', + }, + { + __type__: 'cc.UITransform', + _name: '', + _objFlags: 0, + node: { __id__: 1 }, + _enabled: true, + _priority: 0, + _contentSize: { + __type__: 'cc.Size', + width: plan.prefab.canvas.w, + height: plan.prefab.canvas.h, + }, + _anchorPoint: { + __type__: 'cc.Vec2', + x: plan.prefab.anchor.x, + y: plan.prefab.anchor.y, + }, + _id: '', + __prefab: { __id__: 3 }, + }, + { + __type__: 'cc.CompPrefabInfo', + fileId: uiFileId, + }, + { + __type__: 'cc.Sprite', + _name: '', + _objFlags: 0, + node: { __id__: 1 }, + _enabled: true, + _srcBlendFactor: 2, + _dstBlendFactor: 4, + _color: { __type__: 'cc.Color', r: 255, g: 255, b: 255, a: 255 }, + _sharedMaterial: null, + _spriteFrame: masterUuid ? { __uuid__: masterUuid } : null, + _type: 0, + _fillType: 0, + _sizeMode: 0, + _fillCenter: { __type__: 'cc.Vec2', x: 0, y: 0 }, + _fillStart: 0, + _fillRange: 0, + _isTrimmedMode: false, + _useGrayscale: false, + _atlas: null, + _id: '', + __prefab: { __id__: 5 }, + }, + { + __type__: 'cc.CompPrefabInfo', + fileId: spriteFileId, + }, + { + __type__: 'cc.Animation', + _name: '', + _objFlags: 0, + node: { __id__: 1 }, + _enabled: true, + playOnLoad: Boolean(firstAnimationRef), + _clips: animationRefs, + _defaultClip: firstAnimationRef, + _id: '', + __prefab: { __id__: 7 }, + }, + { + __type__: 'cc.CompPrefabInfo', + fileId: animationFileId, + }, + { + __type__: 'cc.PrefabInfo', + root: { __id__: 1 }, + asset: { __id__: 0 }, + fileId, + }, + ], + null, + 2, + ) + + return files +} + +/** + * @param {string} value + * @param {string} fallback + * @returns {string} + */ +function safeSegment(value, fallback) { + const normalized = value.normalize('NFKC').replace(/[^\p{L}\p{N}]+/gu, '-').replace(/^-+|-+$/g, '') + return normalized || fallback +} + +/** + * Cocos' prefab fileId is a 24-character base64 token (not a UUID). Keep it + * deterministic so repeated CLI imports produce stable prefab diffs while + * still matching the editor's serialized format. + * @param {string} path + * @returns {string} + */ +function prefabFileId(path) { + return createHash('sha1') + .update(`windup-cocos-prefab:${path}`) + .digest('base64') + .replace(/=+$/g, '') + .slice(0, 24) +} + +/** + * 计算"哪个 spriteFrame uuid 对应哪个文件路径",给 Cocos 端 / .meta 端用。 + * 实际上是 `frame:` 的反向表。 + * + * @param {ImportPlan} plan + * @returns {Map} + */ +export function buildSpriteFrameIndex(plan) { + const idx = new Map() + for (const sf of plan.spriteFrames) { + idx.set(sf.cocosPath, sf.sourcePath) + } + return idx +} diff --git a/tools/cocos-importer/src/import-core.js b/tools/cocos-importer/src/import-core.js new file mode 100644 index 00000000..1f71de5b --- /dev/null +++ b/tools/cocos-importer/src/import-core.js @@ -0,0 +1,205 @@ +import { readStoredZip, flattenZipEntries } from './zip-reader.js' +import { buildManifestFromLegacyMeta, parseManifest } from './manifest-reader.js' +import { planImport } from './asset-planner.js' +import { buildCocosMetaFiles, uuidForPath } from './cocos-bridge.js' +import { IMPORT_LIMITS } from './limits.js' + +const encoder = new TextEncoder() +const decoder = new TextDecoder('utf-8') + +/** + * @typedef {{ relativePath: string, data: Uint8Array, size: number, rootDir: string }} FlatEntry + * @typedef {{ + * manifest: import('./manifest-reader.js').WindupCocosManifest, + * manifestText: string, + * plan: import('./asset-planner.js').ImportPlan, + * packFolder: string, + * files: Map, + * summary: { characterName: string, outfitName: string, animationCount: number, frameCount: number, fileCount: number }, + * }} PreparedImport + */ + +/** + * Convert a STORED Windup ZIP entirely in memory. + * @param {Uint8Array} input + * @returns {PreparedImport} + */ +export function prepareImport(input) { + return prepareImportFromEntries(flattenZipEntries(readStoredZip(input))) +} + +/** + * Shared entry point for the ZIP adapter and the CLI's legacy frames adapter. + * @param {FlatEntry[]} entries + * @returns {PreparedImport} + */ +export function prepareImportFromEntries(entries) { + if (entries.length === 0) throw new Error('IMPORT_EMPTY: 资产包没有文件') + const byPath = new Map(entries.map((entry) => [entry.relativePath, entry])) + const manifestEntry = byPath.get('targets/cocos-creator/cocos-import.json') + const legacyMetaEntry = byPath.get('meta.json') + + let manifest + let manifestText + if (manifestEntry) { + manifestText = decoder.decode(manifestEntry.data) + manifest = parseManifest(manifestText) + } else if (legacyMetaEntry) { + let legacy + try { + legacy = JSON.parse(decoder.decode(legacyMetaEntry.data)) + } catch (error) { + throw new Error(`IMPORT_MANIFEST_INVALID: 旧版 meta.json 不是合法 JSON: ${error instanceof Error ? error.message : String(error)}`) + } + manifest = buildManifestFromLegacyMeta(legacy) + manifestText = `${JSON.stringify(manifest, null, 2)}\n` + } else { + throw new Error('IMPORT_MANIFEST_MISSING: 找不到 targets/cocos-creator/cocos-import.json 或 meta.json') + } + + const plan = planImport(manifest) + const files = new Map() + const sources = [] + let expandedBytes = 0 + for (const spriteFrame of plan.spriteFrames) { + const source = byPath.get(spriteFrame.sourcePath) + if (!source) throw new Error(`IMPORT_SOURCE_MISSING: ${spriteFrame.sourcePath}`) + expandedBytes += source.data.byteLength + if (expandedBytes > IMPORT_LIMITS.expandedBytes) { + throw new Error(`IMPORT_OUTPUT_TOO_LARGE: 素材展开超过 ${IMPORT_LIMITS.expandedBytes} 字节`) + } + sources.push([spriteFrame, source]) + } + for (const [spriteFrame, source] of sources) { + files.set(spriteFrame.cocosPath, copyBytes(source.data)) + } + + const generated = buildCocosMetaFiles(manifest, plan, entries[0].rootDir) + for (const [path, text] of Object.entries(generated)) files.set(path, encoder.encode(text)) + + for (const path of ['meta.json', 'schema.json', 'README.md']) { + const entry = byPath.get(path) + if (entry) files.set(`${plan.packFolder}/${path}`, copyBytes(entry.data)) + } + files.set(`${plan.packFolder}/cocos-import.json`, encoder.encode(manifestText)) + addAuxiliaryMetaFiles(files, plan.packFolder) + + const prepared = { + manifest, + manifestText, + plan, + packFolder: plan.packFolder, + files, + summary: { + characterName: manifest.package.character_name, + outfitName: manifest.package.outfit_name, + animationCount: plan.animations.length, + frameCount: plan.animations.reduce((total, animation) => total + animation.frames.length, 0), + fileCount: files.size, + }, + } + validatePreparedImport(prepared) + return prepared +} + +function addAuxiliaryMetaFiles(files, packFolder) { + const assetPaths = [...files.keys()].filter((path) => !path.endsWith('.meta')) + const directories = new Set() + for (const path of assetPaths) { + let directory = path.slice(0, path.lastIndexOf('/')) + while (directory.startsWith(`${packFolder}/`)) { + directories.add(directory) + directory = directory.slice(0, directory.lastIndexOf('/')) + } + } + for (const directory of directories) { + const metaPath = `${directory}.meta` + if (!files.has(metaPath)) files.set(metaPath, encoder.encode(auxiliaryMeta(directory, 'directory'))) + } + for (const path of assetPaths) { + const importer = path.endsWith('.json') ? 'json' : path.endsWith('.md') ? 'text' : null + const metaPath = `${path}.meta` + if (importer && !files.has(metaPath)) files.set(metaPath, encoder.encode(auxiliaryMeta(path, importer))) + } +} + +function auxiliaryMeta(path, importer) { + return JSON.stringify( + { + ver: importer === 'directory' ? '1.2.0' : importer === 'json' ? '2.0.1' : '1.0.1', + importer, + imported: true, + uuid: uuidForPath(path), + files: importer === 'directory' ? [] : ['.json'], + subMetas: {}, + userData: {}, + }, + null, + 2, + ) +} + +/** + * Validate output completeness and every serialized asset UUID reference. + * @param {PreparedImport} prepared + * @returns {PreparedImport['summary']} + */ +export function validatePreparedImport(prepared) { + const required = new Set([ + ...prepared.plan.spriteFrames.flatMap((asset) => [asset.cocosPath, `${asset.cocosPath}.meta`]), + ...prepared.plan.animations.flatMap((animation) => { + const path = `${prepared.packFolder}/animations/${animation.name}.anim` + return [path, `${path}.meta`] + }), + prepared.plan.prefab.cocosPath, + `${prepared.plan.prefab.cocosPath}.meta`, + `${prepared.packFolder}/cocos-import.json`, + ]) + for (const path of required) { + if (!prepared.files.has(path)) throw new Error(`IMPORT_OUTPUT_MISSING: ${path}`) + } + + const definedUuids = new Set() + for (const [path, bytes] of prepared.files) { + if (!path.endsWith('.meta')) continue + const meta = parseGeneratedJson(bytes, path) + collectDefinedUuids(meta, definedUuids) + } + for (const [path, bytes] of prepared.files) { + if (!path.endsWith('.anim') && !path.endsWith('.prefab')) continue + const asset = parseGeneratedJson(bytes, path) + for (const uuid of collectReferencedUuids(asset)) { + if (!definedUuids.has(uuid)) throw new Error(`IMPORT_UUID_UNRESOLVED: ${path} -> ${uuid}`) + } + } + return prepared.summary +} + +function copyBytes(bytes) { + return new Uint8Array(bytes) +} + +function parseGeneratedJson(bytes, path) { + try { + return JSON.parse(decoder.decode(bytes)) + } catch (error) { + throw new Error(`IMPORT_OUTPUT_JSON_INVALID: ${path}: ${error instanceof Error ? error.message : String(error)}`) + } +} + +function collectDefinedUuids(value, output) { + if (!value || typeof value !== 'object') return + if (typeof value.uuid === 'string') output.add(value.uuid) + if (value.subMetas && typeof value.subMetas === 'object') { + for (const subMeta of Object.values(value.subMetas)) collectDefinedUuids(subMeta, output) + } +} + +function collectReferencedUuids(value, output = []) { + if (!value || typeof value !== 'object') return output + if (typeof value.__uuid__ === 'string') output.push(value.__uuid__) + for (const child of Array.isArray(value) ? value : Object.values(value)) { + collectReferencedUuids(child, output) + } + return output +} diff --git a/tools/cocos-importer/src/limits.js b/tools/cocos-importer/src/limits.js new file mode 100644 index 00000000..d734165f --- /dev/null +++ b/tools/cocos-importer/src/limits.js @@ -0,0 +1,8 @@ +export const IMPORT_LIMITS = Object.freeze({ + zipEntries: 4096, + zipEntryBytes: 32 * 1024 * 1024, + expandedBytes: 256 * 1024 * 1024, + actions: 128, + framesPerAction: 2048, + totalFrames: 4096, +}) diff --git a/tools/cocos-importer/src/manifest-reader.js b/tools/cocos-importer/src/manifest-reader.js new file mode 100644 index 00000000..e8e9f8e1 --- /dev/null +++ b/tools/cocos-importer/src/manifest-reader.js @@ -0,0 +1,353 @@ +// 解析与校验 Windup → Cocos Creator 适配包里的 cocos-import.json。 +// 暴露纯函数,无副作用,便于 Node CLI 与 Cocos 扩展共享。 + +import { IMPORT_LIMITS } from './limits.js' + +/** + * @typedef {{ + * schema_version: string, + * experimental: true, + * engine: 'cocos-creator', + * upstream_issue: number, + * package: { character_id: string, character_name: string, outfit_id: string, outfit_name: string, canvas: {w:number, h:number} }, + * master: { file: string, anchor: {x:number, y:number}, anchor_cocos: {x:number, y:number} }, + * actions: Array<{ + * id: string, name: string, export_name: string, direction: string, + * fps: number, timing_mode?: 'constant-fps'|'per-frame', loop: boolean, quality_status: 'passed'|'pending'|'failed', + * anchor: {x:number, y:number}, anchor_cocos: {x:number, y:number}, + * foot_y: number, frames: Array<{index:number, file:string, duration_ms: number|null}>, + * atlas: { file: string, cols: number, rows: number, cell: {w:number, h:number} } + * }> + * }} WindupCocosManifest + */ + +const REQUIRED_TOP_KEYS = [ + 'schema_version', + 'experimental', + 'engine', + 'upstream_issue', + 'package', + 'master', + 'actions', +] + +const REQUIRED_PACKAGE_KEYS = [ + 'character_id', + 'character_name', + 'outfit_id', + 'outfit_name', + 'canvas', +] + +const REQUIRED_MASTER_KEYS = ['file', 'anchor', 'anchor_cocos'] + +const REQUIRED_ACTION_KEYS = [ + 'id', + 'name', + 'export_name', + 'direction', + 'fps', + 'loop', + 'quality_status', + 'anchor', + 'anchor_cocos', + 'foot_y', + 'frames', + 'atlas', +] + +const VALID_QUALITY = new Set(['passed', 'pending', 'failed']) +const VALID_SCHEMA_VERSIONS = new Set([ + 'windup-cocos-import-1.0.0', + 'windup-cocos-import-1.1.0', +]) +const VALID_TIMING_MODES = new Set(['constant-fps', 'per-frame']) +const VALID_DIRECTIONS = new Set([ + 'default', + 'east', 'west', 'north', 'south', + 'north_east', 'north_west', 'south_east', 'south_west', +]) + +function record(value, field) { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new Error(`${field} 必须是对象`) + } + return /** @type {Record} */ (value) +} + +/** + * @param {string} jsonText + * @returns {WindupCocosManifest} + */ +export function parseManifest(jsonText) { + let data + try { + data = JSON.parse(jsonText) + } catch (err) { + throw new Error(`cocos-import.json 不是合法 JSON: ${err instanceof Error ? err.message : String(err)}`) + } + return validateManifest(data) +} + +/** + * @param {unknown} data + * @returns {WindupCocosManifest} + */ +export function validateManifest(data) { + if (typeof data !== 'object' || data === null) { + throw new Error('cocos-import.json 顶层必须是对象') + } + const obj = /** @type {Record} */ (data) + + for (const key of REQUIRED_TOP_KEYS) { + if (!(key in obj)) throw new Error(`cocos-import.json 缺少字段: ${key}`) + } + + if (obj.engine !== 'cocos-creator') { + throw new Error(`engine 必须是 'cocos-creator',实际是 ${JSON.stringify(obj.engine)}`) + } + if (obj.experimental !== true) { + throw new Error(`experimental 必须为 true,实际是 ${JSON.stringify(obj.experimental)}`) + } + if (!VALID_SCHEMA_VERSIONS.has(/** @type {string} */ (obj.schema_version))) { + throw new Error(`schema_version 不受支持: ${obj.schema_version}`) + } + if (!Number.isInteger(obj.upstream_issue) || obj.upstream_issue < 1) { + throw new Error(`upstream_issue 必须为正整数: ${obj.upstream_issue}`) + } + + // package + const pkg = record(obj.package, 'cocos-import.json.package') + for (const key of REQUIRED_PACKAGE_KEYS) { + if (!(key in pkg)) throw new Error(`cocos-import.json.package 缺少字段: ${key}`) + } + for (const key of ['character_id', 'character_name', 'outfit_id', 'outfit_name']) { + nonEmptyString(pkg[key], `package.${key}`) + } + const canvas = record(pkg.canvas, 'cocos-import.json.package.canvas') + if (!Number.isInteger(canvas.w) || canvas.w < 1) throw new Error('package.canvas.w 必须为正整数') + if (!Number.isInteger(canvas.h) || canvas.h < 1) throw new Error('package.canvas.h 必须为正整数') + + // master + const master = record(obj.master, 'cocos-import.json.master') + for (const key of REQUIRED_MASTER_KEYS) { + if (!(key in master)) throw new Error(`cocos-import.json.master 缺少字段: ${key}`) + } + assetPath(master.file, 'master.file') + anchor(master.anchor, 'master.anchor') + anchor(master.anchor_cocos, 'master.anchor_cocos') + + // actions + if (!Array.isArray(obj.actions)) { + throw new Error('cocos-import.json.actions 必须是数组') + } + const actions = /** @type {Array>} */ (obj.actions) + if (actions.length > IMPORT_LIMITS.actions) { + throw new Error(`actions 动作数超过限制 ${IMPORT_LIMITS.actions}`) + } + let totalFrames = 0 + for (let i = 0; i < actions.length; i += 1) { + totalFrames += validateAction(actions[i], i) + if (totalFrames > IMPORT_LIMITS.totalFrames) { + throw new Error(`actions 总帧数超过限制 ${IMPORT_LIMITS.totalFrames}`) + } + } + + return /** @type {WindupCocosManifest} */ (data) +} + +/** + * 把旧版 Windup action-assets 包里的 meta.json 转成当前 Cocos 适配清单。 + * 旧包没有 targets/cocos-creator/cocos-import.json,但它仍包含导入器所需 + * 的角色、画布、动作、帧和图集信息;帧时长按旧包的 fps 补齐。 + * + * @param {Record} legacy + * @returns {WindupCocosManifest} + */ +export function buildManifestFromLegacyMeta(legacy) { + const character = record(legacy.character, 'meta.json.character') + const outfit = record(legacy.outfit, 'meta.json.outfit') + const canvas = record(legacy.canvas, 'meta.json.canvas') + const actions = Array.isArray(legacy.actions) ? legacy.actions : [] + const fallbackAnchor = { x: 0.5, y: 0.92 } + const masterAnchor = firstAnchor(actions) || fallbackAnchor + const manifest = { + schema_version: 'windup-cocos-import-1.1.0', + experimental: true, + engine: 'cocos-creator', + upstream_issue: 94, + package: { + character_id: String(character.id ?? ''), + character_name: String(character.name ?? ''), + outfit_id: String(outfit.id ?? ''), + outfit_name: String(outfit.name ?? ''), + canvas: { w: canvas.w, h: canvas.h }, + }, + master: { + file: typeof character.image === 'string' ? character.image : 'character/master.png', + anchor: masterAnchor, + anchor_cocos: { x: masterAnchor.x, y: 1 - masterAnchor.y }, + }, + actions: actions.map((rawAction, index) => { + const action = record(rawAction, `meta.json.actions[${index}]`) + const anchor = anchorOrFallback(action.anchor, masterAnchor) + const fps = Number(action.fps) + const frameList = Array.isArray(action.frames) ? action.frames : [] + const atlas = record(action.atlas, `meta.json.actions[${index}].atlas`) + return { + id: String(action.id ?? `legacy-action-${index}`), + name: String(action.name ?? `action-${index}`), + export_name: String(action.name ?? `action-${index}`), + direction: validDirection(action.direction) ? action.direction : 'default', + fps, + timing_mode: 'constant-fps', + loop: action.loop !== false, + quality_status: action.quality_status === 'failed' || action.quality_status === 'pending' + ? action.quality_status + : 'passed', + anchor, + anchor_cocos: { x: anchor.x, y: 1 - anchor.y }, + foot_y: Number.isFinite(action.foot_y) ? action.foot_y : 0, + frames: frameList.map((rawFrame, frameIndex) => { + const frame = record(rawFrame, `meta.json.actions[${index}].frames[${frameIndex}]`) + return { + index: frame.index ?? frameIndex, + file: String(frame.file ?? ''), + duration_ms: null, + } + }), + atlas, + } + }), + } + return validateManifest(manifest) +} + +function firstAnchor(actions) { + for (const rawAction of actions) { + if (rawAction && typeof rawAction === 'object' && !Array.isArray(rawAction)) { + const anchor = anchorOrFallback(rawAction.anchor, null) + if (anchor) return anchor + } + } + return null +} + +function anchorOrFallback(value, fallback) { + if ( + value && + typeof value === 'object' && + !Array.isArray(value) && + Number.isFinite(value.x) && + Number.isFinite(value.y) && + value.x >= 0 && + value.x <= 1 && + value.y >= 0 && + value.y <= 1 + ) { + return { x: value.x, y: value.y } + } + return fallback +} + +function validDirection(value) { + return typeof value === 'string' && VALID_DIRECTIONS.has(value) +} + +function validateAction(action, index) { + action = record(action, `actions[${index}]`) + for (const key of REQUIRED_ACTION_KEYS) { + if (!(key in action)) throw new Error(`actions[${index}] 缺少字段: ${key}`) + } + for (const key of ['id', 'name', 'export_name']) { + nonEmptyString(action[key], `actions[${index}].${key}`) + } + if (!VALID_QUALITY.has(/** @type {string} */ (action.quality_status))) { + throw new Error(`actions[${index}].quality_status 非法: ${action.quality_status}`) + } + if (!VALID_DIRECTIONS.has(/** @type {string} */ (action.direction))) { + throw new Error(`actions[${index}].direction 非法: ${action.direction}`) + } + if (!Number.isFinite(/** @type {number} */ (action.fps)) || /** @type {number} */ (action.fps) <= 0) { + throw new Error(`actions[${index}].fps 必须为正数`) + } + if ( + action.timing_mode !== undefined && + !VALID_TIMING_MODES.has(/** @type {string} */ (action.timing_mode)) + ) { + throw new Error(`actions[${index}].timing_mode 非法: ${action.timing_mode}`) + } + if (typeof action.loop !== 'boolean') { + throw new Error(`actions[${index}].loop 必须是布尔值`) + } + anchor(action.anchor, `actions[${index}].anchor`) + anchor(action.anchor_cocos, `actions[${index}].anchor_cocos`) + if (!Number.isFinite(/** @type {number} */ (action.foot_y)) || /** @type {number} */ (action.foot_y) < 0) { + throw new Error(`actions[${index}].foot_y 必须是非负数`) + } + const frames = action.frames + if (!Array.isArray(frames)) throw new Error(`actions[${index}].frames 必须是数组`) + if (frames.length === 0) { + throw new Error(`actions[${index}].frames 不能为空`) + } + if (frames.length > IMPORT_LIMITS.framesPerAction) { + throw new Error(`actions[${index}] 单动作帧数超过限制 ${IMPORT_LIMITS.framesPerAction}`) + } + const atlas = record(action.atlas, `actions[${index}].atlas`) + assetPath(atlas.file, `actions[${index}].atlas.file`) + if (!Number.isInteger(/** @type {number} */ (atlas.cols)) || /** @type {number} */ (atlas.cols) < 1) { + throw new Error(`actions[${index}].atlas.cols 必须为正整数`) + } + if (!Number.isInteger(/** @type {number} */ (atlas.rows)) || /** @type {number} */ (atlas.rows) < 1) { + throw new Error(`actions[${index}].atlas.rows 必须为正整数`) + } + const cell = record(atlas.cell, `actions[${index}].atlas.cell`) + if (!Number.isInteger(cell.w) || cell.w < 1) { + throw new Error(`actions[${index}].atlas.cell.w 必须为正整数`) + } + if (!Number.isInteger(cell.h) || cell.h < 1) { + throw new Error(`actions[${index}].atlas.cell.h 必须为正整数`) + } + if (frames.length > atlas.cols * atlas.rows) { + throw new Error(`actions[${index}].frames 超出图集容量`) + } + for (let j = 0; j < frames.length; j += 1) { + const f = record(frames[j], `actions[${index}].frames[${j}]`) + if (!Number.isInteger(f.index) || f.index !== j) { + throw new Error(`actions[${index}].frames[${j}].index 必须从 0 连续递增`) + } + assetPath(f.file, `actions[${index}].frames[${j}].file`) + if (f.duration_ms !== null && (!Number.isFinite(f.duration_ms) || f.duration_ms < 0)) { + throw new Error(`actions[${index}].frames[${j}].duration_ms 必须是非负数或 null`) + } + } + return frames.length +} + +function nonEmptyString(value, field) { + if (typeof value !== 'string' || value.trim() === '') { + throw new Error(`${field} 必须是非空字符串`) + } +} + +function assetPath(value, field) { + nonEmptyString(value, field) + if ( + value.includes('\\') || + value.includes('\0') || + value.startsWith('/') || + /^[A-Za-z]:/.test(value) || + value.split('/').some((segment) => segment === '' || segment === '..' || segment === '.') + ) { + throw new Error(`${field} 必须是安全的相对路径`) + } +} + +function anchor(value, field) { + const point = record(value, field) + for (const axis of ['x', 'y']) { + if (!Number.isFinite(point[axis]) || point[axis] < 0 || point[axis] > 1) { + throw new Error(`${field}.${axis} 必须在 0 到 1 之间`) + } + } +} diff --git a/tools/cocos-importer/src/zip-reader.js b/tools/cocos-importer/src/zip-reader.js new file mode 100644 index 00000000..4ff2c24d --- /dev/null +++ b/tools/cocos-importer/src/zip-reader.js @@ -0,0 +1,181 @@ +// Minimal ZIP reader for STORED-only (no compression) ZIPs. +// Windup's asset-export.ts always writes stored (compression method 0) so this +// is sufficient and avoids pulling in a full ZIP library. +// +// Layout: https://pkware.files.wordpress.com/2024/06/appnote-6.0.0-20240424.pdf +// [Local File Header + file data] * N +// [Central Directory File Header] * N +// [End of Central Directory Record] + +import { IMPORT_LIMITS } from './limits.js' + +/** + * @typedef {{ + * name: string, + * data: Uint8Array, + * size: number, + * }} ZipEntry + */ + +/** + * @param {Uint8Array} bytes + * @param {{maxEntries?:number, maxEntryBytes?:number, maxTotalBytes?:number}} [limits] + * @returns {ZipEntry[]} + */ +export function readStoredZip(bytes, limits = {}) { + const maxEntries = limits.maxEntries ?? IMPORT_LIMITS.zipEntries + const maxEntryBytes = limits.maxEntryBytes ?? IMPORT_LIMITS.zipEntryBytes + const maxTotalBytes = limits.maxTotalBytes ?? IMPORT_LIMITS.expandedBytes + if (!(bytes instanceof Uint8Array) || bytes.length < 22) throw new Error('ZIP: 文件过短') + const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength) + const u16 = (off) => dv.getUint16(off, true) + const u32 = (off) => dv.getUint32(off, true) + const within = (offset, length, limit = bytes.length) => + Number.isSafeInteger(offset) && Number.isSafeInteger(length) && offset >= 0 && length >= 0 && offset + length <= limit + const decodeName = (nameBytes) => { + try { + return new TextDecoder('utf-8', { fatal: true }).decode(nameBytes) + } catch { + throw new Error('ZIP: 文件名不是合法 UTF-8') + } + } + + // Find End of Central Directory Record + let eocd = -1 + for (let i = bytes.length - 22; i >= 0; i -= 1) { + if (u32(i) === 0x06054b50) { + eocd = i + break + } + } + if (eocd < 0) throw new Error('ZIP: 找不到 End of Central Directory Record') + if (!within(eocd, 22)) throw new Error('ZIP: EOCD 不完整') + const commentLength = u16(eocd + 20) + if (!within(eocd, 22 + commentLength)) throw new Error('ZIP: EOCD 注释越界') + if (u16(eocd + 4) !== 0 || u16(eocd + 6) !== 0 || u16(eocd + 8) !== u16(eocd + 10)) { + throw new Error('ZIP: 不支持多磁盘压缩包') + } + + const total = u16(eocd + 10) + if (total > maxEntries) throw new Error(`ZIP: 条目数超过限制 ${maxEntries}`) + const cdSize = u32(eocd + 12) + const cdOffset = u32(eocd + 16) + const cdEnd = cdOffset + cdSize + if (!within(cdOffset, cdSize, eocd) || cdEnd !== eocd) throw new Error('ZIP: 中央目录越界') + const entries = [] + const names = new Set() + let totalBytes = 0 + + let p = cdOffset + for (let i = 0; i < total; i += 1) { + if (!within(p, 46, cdEnd)) throw new Error(`ZIP: 中央目录条目越界 @${p}`) + if (u32(p) !== 0x02014b50) throw new Error(`ZIP: 中央目录签名错 @${p}`) + const hostSystem = u16(p + 4) >> 8 + const unixMode = u32(p + 38) >>> 16 + if (hostSystem === 3 && (unixMode & 0xf000) === 0xa000) { + throw new Error('ZIP: 包含符号链接') + } + const compMethod = u16(p + 10) + if (compMethod !== 0) { + throw new Error( + `ZIP: 不支持压缩方法 ${compMethod} (仅支持 STORED);${{ + 8: 'Deflate', + 12: 'Bzip2', + 14: 'LZMA', + }[compMethod] ?? ''}`, + ) + } + const flags = u16(p + 8) + if ((flags & 0x0001) !== 0) throw new Error('ZIP: 不支持加密条目') + if ((flags & 0x0008) !== 0) throw new Error('ZIP: 不支持数据描述符') + const expectedCrc = u32(p + 16) + const csize = u32(p + 20) + const usize = u32(p + 24) + if (csize !== usize) throw new Error('ZIP: STORED 条目压缩前后大小不一致') + if (usize > maxEntryBytes) throw new Error(`ZIP: 单条目超过限制 ${maxEntryBytes}`) + totalBytes += usize + if (totalBytes > maxTotalBytes) throw new Error(`ZIP: 总解包大小超过限制 ${maxTotalBytes}`) + const fnLen = u16(p + 28) + const exLen = u16(p + 30) + const cmLen = u16(p + 32) + const lhOffset = u32(p + 42) + const centralLength = 46 + fnLen + exLen + cmLen + if (!within(p, centralLength, cdEnd)) throw new Error(`ZIP: 中央目录字段越界 @${p}`) + const nameBytes = bytes.subarray(p + 46, p + 46 + fnLen) + const name = decodeName(nameBytes) + assertSafeZipPath(name) + if (names.has(name)) throw new Error(`ZIP: 包含重复路径: ${name}`) + names.add(name) + p += centralLength + + // Local file header + if (!within(lhOffset, 30, cdOffset)) throw new Error(`ZIP: 本地头越界 @${lhOffset}`) + if (u32(lhOffset) !== 0x04034b50) throw new Error(`ZIP: 本地头签名错 @${lhOffset}`) + if (u16(lhOffset + 6) !== flags) throw new Error(`ZIP: 本地头标志不一致: ${name}`) + if (u16(lhOffset + 8) !== compMethod) throw new Error(`ZIP: 本地头压缩方法不一致: ${name}`) + if (u32(lhOffset + 14) !== expectedCrc) throw new Error(`ZIP: 本地头 CRC 不一致: ${name}`) + if (u32(lhOffset + 18) !== csize || u32(lhOffset + 22) !== usize) { + throw new Error(`ZIP: 本地头大小不一致: ${name}`) + } + const lhFn = u16(lhOffset + 26) + const lhEx = u16(lhOffset + 28) + const dataStart = lhOffset + 30 + lhFn + lhEx + if (!within(lhOffset, 30 + lhFn + lhEx + csize, cdOffset)) { + throw new Error(`ZIP: 文件内容越界: ${name}`) + } + const localName = decodeName(bytes.subarray(lhOffset + 30, lhOffset + 30 + lhFn)) + if (localName !== name) throw new Error(`ZIP: 中央目录和本地头文件名不一致: ${name}`) + const data = bytes.subarray(dataStart, dataStart + csize) + if (crc32(data) !== expectedCrc) throw new Error(`ZIP: CRC 校验失败: ${name}`) + entries.push({ name, data, size: csize }) + } + if (p !== cdEnd) throw new Error('ZIP: 中央目录大小不一致') + return entries +} + +/** + * Extract entries to a flat list filtered by prefix. The root package directory + * (e.g. "Hero-char-42-Ranger-outfit-7/") is stripped, leaving relative paths. + * + * @param {ZipEntry[]} entries + * @returns {{relativePath: string, data: Uint8Array, size: number, rootDir: string}[]} + */ +export function flattenZipEntries(entries) { + if (entries.length === 0) throw new Error('ZIP: 空包') + const firstName = entries[0].name + const slash = firstName.indexOf('/') + if (slash < 0) throw new Error(`ZIP: 顶层条目没有包根目录: ${firstName}`) + const rootDir = firstName.slice(0, slash) + const prefix = `${rootDir}/` + return entries + .filter((e) => e.name.startsWith(prefix)) + .map((e) => ({ + rootDir, + relativePath: e.name.slice(prefix.length), + data: e.data, + size: e.size, + })) +} + +function assertSafeZipPath(name) { + if ( + name.includes('\\') || + name.includes('\0') || + name.startsWith('/') || + /^[A-Za-z]:/.test(name) || + name.split('/').some((segment) => segment === '..' || segment === '.' || segment === '') + ) { + throw new Error(`ZIP: 包内路径不安全: ${name}`) + } +} + +function crc32(bytes) { + let crc = 0xffffffff + for (const byte of bytes) { + crc ^= byte + for (let bit = 0; bit < 8; bit += 1) { + crc = (crc >>> 1) ^ (0xedb88320 & -(crc & 1)) + } + } + return (crc ^ 0xffffffff) >>> 0 +} diff --git a/tools/cocos-importer/test/asset-planner.test.mjs b/tools/cocos-importer/test/asset-planner.test.mjs new file mode 100644 index 00000000..aa1650c0 --- /dev/null +++ b/tools/cocos-importer/test/asset-planner.test.mjs @@ -0,0 +1,134 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { planImport } from '../src/asset-planner.js' + +const MANIFEST = { + schema_version: 'windup-cocos-import-1.0.0', + experimental: true, + engine: 'cocos-creator', + upstream_issue: 94, + package: { + character_id: 'c1', + character_name: 'Hero', + outfit_id: 'o1', + outfit_name: 'Ranger', + canvas: { w: 64, h: 64 }, + }, + master: { + file: 'character/master.png', + anchor: { x: 0.5, y: 0.92 }, + anchor_cocos: { x: 0.5, y: 0.08 }, + }, + actions: [ + { + id: 'walk', + name: 'Walk', + export_name: 'Walk', + direction: 'default', + fps: 8, + loop: true, + quality_status: 'passed', + anchor: { x: 0.5, y: 0.92 }, + anchor_cocos: { x: 0.5, y: 0.08 }, + foot_y: 58, + frames: [ + { index: 0, file: 'Walk_000.png', duration_ms: 125 }, + { index: 1, file: 'Walk_001.png', duration_ms: 125 }, + { index: 2, file: 'Walk_002.png', duration_ms: 0 }, + ], + atlas: { file: 'atlas/Walk.png', cols: 3, rows: 1, cell: { w: 64, h: 64 } }, + }, + { + id: 'attack', + name: 'Attack', + export_name: 'Attack', + direction: 'east', + fps: 12, + loop: false, + quality_status: 'passed', + anchor: { x: 0.5, y: 0.92 }, + anchor_cocos: { x: 0.5, y: 0.08 }, + foot_y: 58, + frames: [ + { index: 0, file: 'Attack_000.png', duration_ms: 100 }, + { index: 1, file: 'Attack_001.png', duration_ms: 200 }, + ], + atlas: { file: 'atlas/Attack.png', cols: 2, rows: 1, cell: { w: 64, h: 64 } }, + }, + ], +} + +test('planImport 规划 master + N 个动作 + atlas', () => { + const plan = planImport(MANIFEST) + assert.equal(plan.packFolder, 'windup-imports/Hero/Ranger') + // 1 master + 2 actions: Walk(3 frames + 1 atlas) + Attack(2 frames + 1 atlas) = 8 + assert.equal(plan.spriteFrames.length, 1 + 4 + 3) + assert.equal(plan.animations.length, 2) +}) + +test('planImport master anchor 来自 manifest', () => { + const plan = planImport(MANIFEST) + assert.equal(plan.prefab.anchor.x, 0.5) + assert.equal(plan.prefab.anchor.y, 0.08) + assert.equal(plan.prefab.canvas.w, 64) + assert.equal(plan.prefab.canvas.h, 64) +}) + +test('planImport 动画 duration 等于帧 duration_ms 之和(秒)', () => { + const plan = planImport(MANIFEST) + const walk = plan.animations.find((a) => a.name === 'Walk') + // 125 + 125 + 125(fallback 1000/8)=375ms = 0.375s + assert.ok(Math.abs(walk.duration - 0.375) < 0.01, `walk.duration=${walk.duration}`) +}) + +test('planImport 保留 export_name 且不重复追加方向后缀', () => { + const plan = planImport(MANIFEST) + const walk = plan.animations.find((a) => a.name === 'Walk') + assert.ok(walk, 'Walk animation 存在') + assert.equal(walk.direction, 'default') + const attack = plan.animations.find((a) => a.name === 'Attack') + assert.ok(attack, 'Attack 存在') + assert.equal(attack.direction, 'east') +}) + +test('planImport spriteFrame 路径与 export_name 对齐', () => { + const plan = planImport(MANIFEST) + const walkFrames = plan.spriteFrames.filter((s) => s.cocosPath.includes('/Walk/')) + assert.equal(walkFrames.length, 4) // 3 frames + 1 atlas + assert.ok(walkFrames.some((s) => s.cocosPath.endsWith('/Walk/Walk_000.png'))) + assert.ok(walkFrames.some((s) => s.cocosPath.endsWith('/Walk/atlas.png'))) + // 源路径:master=character/master.png,frames=frames//,atlas=atlas/.png + assert.ok(walkFrames.find((s) => s.cocosPath.endsWith('Walk_000.png')).sourcePath === 'frames/Walk/Walk_000.png') + assert.ok(walkFrames.find((s) => s.cocosPath.endsWith('atlas.png')).sourcePath === 'atlas/Walk.png') + const secondFrame = walkFrames.find((s) => s.cocosPath.endsWith('Walk_001.png')) + assert.deepEqual(secondFrame.rect, { x: 0, y: 0, w: 64, h: 64 }) +}) + +test('planImport 使用 manifest 声明的 atlas 文件路径', () => { + const custom = JSON.parse(JSON.stringify(MANIFEST)) + custom.actions[0].atlas.file = 'atlas/custom-walk.png' + const plan = planImport(custom) + const atlas = plan.spriteFrames.find((s) => s.cocosPath.endsWith('/Walk/atlas.png')) + assert.equal(atlas.sourcePath, 'atlas/custom-walk.png') +}) + +test('planImport 接受 master 缺失 foot_y(向后兼容)', () => { + const broken = JSON.parse(JSON.stringify(MANIFEST)) + delete broken.master.foot_y + const plan = planImport(broken) + assert.equal(plan.prefab.footY, 0) +}) + +test('planImport 对 1.1 constant-fps 使用精确 index/fps 时间', () => { + const manifest = JSON.parse(JSON.stringify(MANIFEST)) + manifest.schema_version = 'windup-cocos-import-1.1.0' + manifest.actions = [manifest.actions[0]] + manifest.actions[0].fps = 12 + manifest.actions[0].timing_mode = 'constant-fps' + for (const frame of manifest.actions[0].frames) frame.duration_ms = null + + const animation = planImport(manifest).animations[0] + assert.deepEqual(animation.frames.map((frame) => frame.time), [0, 1 / 12, 2 / 12]) + assert.deepEqual(animation.frames.map((frame) => frame.duration), [1 / 12, 1 / 12, 1 / 12]) + assert.equal(animation.duration, 3 / 12) +}) diff --git a/tools/cocos-importer/test/bridge-uuid.test.mjs b/tools/cocos-importer/test/bridge-uuid.test.mjs new file mode 100644 index 00000000..19bc9034 --- /dev/null +++ b/tools/cocos-importer/test/bridge-uuid.test.mjs @@ -0,0 +1,120 @@ +// 验 uuidForPath 确定性 + RFC 4122 格式 + 不同 path 不同 uuid。 +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { buildCocosMetaFiles, uuidForPath } from '../src/cocos-bridge.js' + +test('uuidForPath 总是 Cocos 可保留的 RFC 4122 UUID', () => { + const u = uuidForPath('windup-imports/Hero/Ranger/animations/Walk.anim') + assert.equal(u.length, 36, `length=${u.length}`) + assert.match(u, /^[0-9a-f]{8}-[0-9a-f]{4}-5[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/) +}) + +test('uuidForPath 确定性:同 path → 同 uuid', () => { + const u1 = uuidForPath('windup-imports/Hero/Ranger/animations/Walk.anim') + const u2 = uuidForPath('windup-imports/Hero/Ranger/animations/Walk.anim') + assert.equal(u1, u2) +}) + +test('uuidForPath 唯一性:不同 path → 不同 uuid', () => { + const u1 = uuidForPath('a') + const u2 = uuidForPath('b') + const u3 = uuidForPath('c') + assert.notEqual(u1, u2) + assert.notEqual(u2, u3) + assert.notEqual(u1, u3) +}) + +test('uuidForPath 带 namespace 前缀,不和 Cocos 内部 UUID 撞', () => { + // 使用标准 UUID 形态,避免 Creator 将短 ID 重写成另一套内部 UUID。 + const u = uuidForPath('assets/scenes/main.scene') + assert.match(u, /^[0-9a-f]{8}-[0-9a-f]{4}-5[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/) +}) + +test('buildCocosMetaFiles 生成 Cocos 3.x image 子资源结构', () => { + const plan = { + packFolder: 'windup-imports/Hero/Ranger', + spriteFrames: [ + { + sourcePath: 'character/master.png', + cocosPath: 'windup-imports/Hero/Ranger/textures/Hero-master.png', + rect: { x: 0, y: 0, w: 64, h: 64 }, + trim: { x: 0, y: 0, w: 64, h: 64 }, + }, + ], + animations: [], + prefab: { + cocosPath: 'windup-imports/Hero/Ranger/prefabs/Hero-Ranger.prefab', + nodeName: 'Hero-Ranger', + anchor: { x: 0.5, y: 0.08 }, + canvas: { w: 64, h: 64 }, + }, + } + const files = buildCocosMetaFiles({ package: { character_name: 'Hero' } }, plan, 'Hero-Ranger') + const meta = JSON.parse(files['windup-imports/Hero/Ranger/textures/Hero-master.png.meta']) + assert.equal(meta.importer, 'image') + assert.equal(meta.subMetas['6c48a'].uuid, `${meta.uuid}@6c48a`) + assert.equal(meta.subMetas.f9941.uuid, `${meta.uuid}@f9941`) + assert.equal(meta.subMetas.f9941.name, 'spriteFrame') + assert.equal(meta.subMetas.f9941.userData.imageUuidOrDatabaseUri, `${meta.uuid}@6c48a`) + assert.equal(meta.userData.redirect, `${meta.uuid}@6c48a`) +}) + +test('buildCocosMetaFiles 生成 Creator 3.8 可播放的 SpriteFrame 对象轨道', () => { + const packFolder = 'windup-imports/Hero/Ranger' + const framePaths = [0, 1].map((index) => `${packFolder}/textures/Idle_${index}.png`) + const plan = { + packFolder, + spriteFrames: framePaths.map((cocosPath, index) => ({ + sourcePath: `frames/Idle_${index}.png`, + cocosPath, + rect: { x: 0, y: 0, w: 64, h: 64 }, + trim: { x: 0, y: 0, w: 64, h: 64 }, + })), + animations: [{ + name: 'Idle', + direction: 'default', + fps: 12, + loop: true, + duration: 1 / 6, + frames: framePaths.map((spriteFramePath, index) => ({ + spriteFramePath, + index, + time: index === 0 ? 0 : 0.1, + duration: 1 / 12, + })), + }], + prefab: { + cocosPath: `${packFolder}/prefabs/Hero-Ranger.prefab`, + nodeName: 'Hero-Ranger', + anchor: { x: 0.5, y: 0.08 }, + canvas: { w: 64, h: 64 }, + }, + } + + const files = buildCocosMetaFiles({ package: { character_name: 'Hero' } }, plan, 'Hero-Ranger') + const clip = JSON.parse(files[`${packFolder}/animations/Idle.anim`]) + + assert.equal(clip._duration, 1 / 6) + assert.equal('duration' in clip, false) + assert.equal('curveData' in clip, false) + assert.equal(clip._tracks.length, 1) + + const track = clip._tracks[0] + assert.equal(track.__type__, 'cc.animation.ObjectTrack') + assert.deepEqual(track._binding.path._paths, [ + { __type__: 'cc.animation.ComponentPath', component: 'cc.Sprite' }, + 'spriteFrame', + ]) + assert.deepEqual(track._channel._curve._times, [0, 0.1]) + assert.deepEqual( + track._channel._curve._values.map(({ __uuid__ }) => __uuid__), + framePaths.map((path) => `${uuidForPath(path)}@f9941`), + ) + + const prefab = JSON.parse(files[`${packFolder}/prefabs/Hero-Ranger.prefab`]) + const uiTransform = prefab.find((entry) => entry.__type__ === 'cc.UITransform') + const sprite = prefab.find((entry) => entry.__type__ === 'cc.Sprite') + assert.deepEqual(uiTransform._contentSize, { __type__: 'cc.Size', width: 64, height: 64 }) + assert.equal(sprite._sizeMode, 0) + assert.equal(sprite._isTrimmedMode, false) +}) diff --git a/tools/cocos-importer/test/creator-runtime-check.mjs b/tools/cocos-importer/test/creator-runtime-check.mjs new file mode 100644 index 00000000..79bbd4a3 --- /dev/null +++ b/tools/cocos-importer/test/creator-runtime-check.mjs @@ -0,0 +1,150 @@ +import { createHash, randomUUID } from 'node:crypto' +import { lstat, readFile, readdir } from 'node:fs/promises' +import { basename, dirname, join, relative, resolve } from 'node:path' + +const PROTOCOL = 'windup-cocos-bridge/1.0.0' + +function option(name) { + const index = process.argv.indexOf(name) + return index < 0 ? null : process.argv[index + 1] +} + +function crc32(bytes) { + let crc = 0xffffffff + for (const byte of bytes) { + crc ^= byte + for (let bit = 0; bit < 8; bit += 1) crc = (crc >>> 1) ^ (0xedb88320 & -(crc & 1)) + } + return (crc ^ 0xffffffff) >>> 0 +} + +function storedZip(entries) { + const localParts = [] + const centralParts = [] + let offset = 0 + for (const entry of entries) { + const name = Buffer.from(entry.name.replaceAll('\\', '/')) + const data = Buffer.from(entry.data) + const checksum = crc32(data) + const local = Buffer.alloc(30) + local.writeUInt32LE(0x04034b50, 0) + local.writeUInt16LE(20, 4) + local.writeUInt16LE(0x0800, 6) + local.writeUInt32LE(checksum, 14) + local.writeUInt32LE(data.length, 18) + local.writeUInt32LE(data.length, 22) + local.writeUInt16LE(name.length, 26) + localParts.push(local, name, data) + + const central = Buffer.alloc(46) + central.writeUInt32LE(0x02014b50, 0) + central.writeUInt16LE(20, 4) + central.writeUInt16LE(20, 6) + central.writeUInt16LE(0x0800, 8) + central.writeUInt32LE(checksum, 16) + central.writeUInt32LE(data.length, 20) + central.writeUInt32LE(data.length, 24) + central.writeUInt16LE(name.length, 28) + central.writeUInt32LE(offset, 42) + centralParts.push(central, name) + offset += local.length + name.length + data.length + } + const centralSize = centralParts.reduce((sum, part) => sum + part.length, 0) + const end = Buffer.alloc(22) + end.writeUInt32LE(0x06054b50, 0) + end.writeUInt16LE(entries.length, 8) + end.writeUInt16LE(entries.length, 10) + end.writeUInt32LE(centralSize, 12) + end.writeUInt32LE(offset, 16) + return Buffer.concat([...localParts, ...centralParts, end]) +} + +async function packageEntries(packageRoot, directory = packageRoot) { + const rootName = basename(packageRoot) + const entries = [] + for (const item of await readdir(directory, { withFileTypes: true })) { + const path = join(directory, item.name) + const stat = await lstat(path) + if (stat.isSymbolicLink()) throw new Error(`FIXTURE_SYMLINK_FORBIDDEN: ${path}`) + if (item.isDirectory()) entries.push(...(await packageEntries(packageRoot, path))) + else if (item.isFile()) { + entries.push({ + name: `${rootName}/${relative(packageRoot, path).replaceAll('\\', '/')}`, + data: await readFile(path), + }) + } + } + return entries +} + +async function requestJson(url, init) { + const response = await fetch(url, init) + const body = await response.json() + if (!response.ok) throw new Error(`${response.status} ${JSON.stringify(body)}`) + return body +} + +async function main() { + const code = option('--code') + const frames = option('--frames') + const origin = option('--origin') ?? 'http://localhost:4173' + const baseUrl = option('--bridge') ?? 'http://127.0.0.1:17832' + const repeat = Number(option('--repeat') ?? 1) + if (!/^\d{6}$/.test(code ?? '')) throw new Error('--code 必须是六位连接码') + if (!Number.isInteger(repeat) || repeat < 1 || repeat > 3) throw new Error('--repeat 必须是 1 到 3') + if (!frames || basename(resolve(frames)).toLowerCase() !== 'frames') { + throw new Error('--frames 必须指向资产包的 frames 目录') + } + + const packageRoot = dirname(resolve(frames)) + const entries = await packageEntries(packageRoot) + const zipBytes = storedZip(entries) + const sha256 = createHash('sha256').update(zipBytes).digest('hex') + const pairing = await requestJson(`${baseUrl}/v1/pair`, { + method: 'POST', + headers: { Origin: origin, 'Content-Type': 'application/json' }, + body: JSON.stringify({ code }), + }) + const attempts = [] + for (let attempt = 1; attempt <= repeat; attempt += 1) { + const requestId = randomUUID() + const submitted = await requestJson(`${baseUrl}/v1/imports`, { + method: 'POST', + headers: { + Origin: origin, + Authorization: `Bearer ${pairing.token}`, + 'Content-Type': 'application/zip', + 'X-Windup-Protocol': PROTOCOL, + 'X-Windup-Request-Id': requestId, + 'X-Windup-SHA256': sha256, + }, + body: zipBytes, + }) + + let previousPhase = null + const deadline = Date.now() + 60_000 + while (Date.now() < deadline) { + const job = await requestJson(`${baseUrl}/v1/imports/${submitted.jobId}`, { + headers: { + Origin: origin, + Authorization: `Bearer ${pairing.token}`, + 'X-Windup-Protocol': PROTOCOL, + }, + }) + if (job.phase !== previousPhase) { + console.log(`attempt ${attempt}: ${job.status}: ${job.phase}`) + previousPhase = job.phase + } + if (job.status === 'completed') { + attempts.push({ requestId, jobId: job.jobId, result: job.result }) + break + } + if (job.status === 'failed') throw new Error(JSON.stringify(job.error)) + await new Promise((resolve) => setTimeout(resolve, 250)) + } + if (attempts.length !== attempt) throw new Error('IMPORT_JOB_TIMEOUT') + } + console.log(JSON.stringify({ zipBytes: zipBytes.length, attempts }, null, 2)) +} + +await main() diff --git a/tools/cocos-importer/test/e2e-cli.test.mjs b/tools/cocos-importer/test/e2e-cli.test.mjs new file mode 100644 index 00000000..e130a004 --- /dev/null +++ b/tools/cocos-importer/test/e2e-cli.test.mjs @@ -0,0 +1,244 @@ +// E2E:真的跑 CLI 走完整 ZIP → 输出目录 → 校验内容 +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { execFileSync } from 'node:child_process' +import { existsSync, readFileSync, readdirSync, statSync, rmSync, mkdirSync, writeFileSync, unlinkSync } from 'node:fs' +import { resolve, dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = dirname(__filename) + +// 真正生成的 ZIP 在 frontend/dist/cocos-e2e/ 下面(由 vitest e2e 测试产出)。 +// 这里直接 fork 一个子进程跑 vitest 产出 ZIP,确保 e2e 自包含。 +const repoRoot = resolve(__dirname, '..', '..', '..') +const frontendDir = join(repoRoot, 'frontend') +const zipPath = join(frontendDir, 'dist', 'cocos-e2e', 'windup-Hero-char-42-Ranger-outfit-7.zip') +const outDir = join(__dirname, '.tmp-cli-out') +const framesPackageRoot = join(__dirname, '.tmp-frames-package') + +function ensureZip() { + if (existsSync(zipPath)) return + // 用 vitest 跑一次 extract test 落盘 + execFileSync( + 'cmd', + [ + '/c', + 'npx', + 'vitest', + 'run', + '--passWithNoTests', + 'src/features/export-package/cocos-target.e2e.extract.test.ts', + ], + { cwd: frontendDir, stdio: 'inherit' }, + ) + if (!existsSync(zipPath)) throw new Error(`vitest 跑完也没产出 ZIP: ${zipPath}`) +} + +test('CLI 把 ZIP 解析 + 写到指定目录', () => { + ensureZip() + if (existsSync(outDir)) rmSync(outDir, { recursive: true, force: true }) + mkdirSync(outDir, { recursive: true }) + + const cliPath = join(repoRoot, 'tools', 'cocos-importer', 'bin', 'windup-cocos-import.mjs') + execFileSync('node', [cliPath, zipPath, '--out', outDir, '--force'], { + stdio: 'pipe', + cwd: repoRoot, + }) + + // 检查输出结构 + const packRoot = join(outDir, 'windup-imports', 'Hero', 'Ranger') + assert.ok(existsSync(packRoot), `缺包根: ${packRoot}`) + assert.ok(existsSync(join(packRoot, 'cocos-import.json'))) + assert.ok(existsSync(join(packRoot, 'textures'))) + + // master.png 应该被拷到 textures/-master.png + const masterFile = readdirSync(join(packRoot, 'textures')).find((n) => n.endsWith('.png')) + assert.ok(masterFile, 'master.png 没出现在 textures 目录') + + // 至少一个动作的纹理目录 + const animDir = join(packRoot, 'animations', 'Walk') + assert.ok(existsSync(animDir), `缺动画目录: ${animDir}`) + const frames = readdirSync(animDir) + const framePngs = frames.filter((f) => f.endsWith('.png')) + assert.ok(framePngs.length >= 4, `期望至少 4 张 PNG(3 帧 + 1 atlas),实际 ${framePngs.length}`) + + // 至少一个 .prefab + const prefabDir = join(packRoot, 'prefabs') + assert.ok(existsSync(prefabDir), '缺 prefabs 目录') + const prefabFiles = readdirSync(prefabDir).filter((f) => f.endsWith('.prefab')) + assert.equal(prefabFiles.length, 1) + + // .prefab 是合法 JSON + const prefabContent = readFileSync(join(prefabDir, prefabFiles[0]), 'utf-8') + const prefabJson = JSON.parse(prefabContent) + const prefabAsset = Array.isArray(prefabJson) ? prefabJson[0] : prefabJson + assert.equal(prefabAsset.__type__, 'cc.Prefab') + assert.ok(Array.isArray(prefabJson), 'Cocos Creator 3.x prefab 应使用对象数组序列化') + assert.equal(prefabAsset.data.__id__, 1) + + // cocos-import.json 副本存在 + const manifest = JSON.parse(readFileSync(join(packRoot, 'cocos-import.json'), 'utf-8')) + assert.equal(manifest.engine, 'cocos-creator') +}) + +test('CLI --dry-run 不写任何文件', () => { + ensureZip() + if (existsSync(outDir)) rmSync(outDir, { recursive: true, force: true }) + + const cliPath = join(repoRoot, 'tools', 'cocos-importer', 'bin', 'windup-cocos-import.mjs') + execFileSync('node', [cliPath, zipPath, '--out', outDir, '--dry-run'], { + stdio: 'pipe', + cwd: repoRoot, + }) + assert.equal(existsSync(outDir), false, 'dry-run 不应创建输出目录') +}) + +test('CLI 允许直接选择旧资产包的 frames 目录', () => { + if (existsSync(framesPackageRoot)) rmSync(framesPackageRoot, { recursive: true, force: true }) + if (existsSync(outDir)) rmSync(outDir, { recursive: true, force: true }) + + const framesDir = join(framesPackageRoot, 'frames') + const idleDir = join(framesDir, '待机') + mkdirSync(idleDir, { recursive: true }) + mkdirSync(join(framesPackageRoot, 'character'), { recursive: true }) + mkdirSync(join(framesPackageRoot, 'atlas'), { recursive: true }) + + const masterBytes = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x01]) + const frameBytes = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x02]) + const atlasBytes = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x03]) + writeFileSync(join(framesPackageRoot, 'character', 'master.png'), masterBytes) + writeFileSync(join(idleDir, '待机_000.png'), frameBytes) + writeFileSync(join(framesPackageRoot, 'atlas', '待机.png'), atlasBytes) + writeFileSync( + join(framesPackageRoot, 'meta.json'), + JSON.stringify({ + character: { id: '46', name: '网站看板娘', image: 'character/master.png' }, + outfit: { id: 'default', name: '默认造型' }, + canvas: { w: 1, h: 1 }, + actions: [ + { + id: 'idle', + name: '待机', + fps: 12, + loop: true, + anchor: { x: 0.5, y: 0.92 }, + frames: [{ index: 0, file: '待机_000.png' }], + atlas: { file: 'atlas/待机.png', cols: 1, rows: 1, cell: { w: 1, h: 1 } }, + }, + ], + }), + ) + + try { + const cliPath = join(repoRoot, 'tools', 'cocos-importer', 'bin', 'windup-cocos-import.mjs') + execFileSync('node', [cliPath, framesDir, '--out', outDir, '--force'], { + stdio: 'pipe', + cwd: repoRoot, + }) + + const packRoot = join(outDir, 'windup-imports', '网站看板娘', '默认造型') + assert.deepEqual( + readFileSync(join(packRoot, 'animations', '待机', '待机_000.png')), + frameBytes, + '导出的动画帧必须来自用户选中的 frames 目录', + ) + } finally { + if (existsSync(framesPackageRoot)) rmSync(framesPackageRoot, { recursive: true, force: true }) + } +}) + +test('CLI 拒绝不存在的输入文件', () => { + const cliPath = join(repoRoot, 'tools', 'cocos-importer', 'bin', 'windup-cocos-import.mjs') + let exitCode = 0 + try { + execFileSync('node', [cliPath, join(__dirname, '.tmp-does-not-exist.zip'), '--out', outDir], { + stdio: 'pipe', + }) + } catch (err) { + exitCode = err.status + } + assert.notEqual(exitCode, 0, '不存在的文件应让 CLI 退出非 0') +}) + +test('CLI 拒绝会覆盖仓库根目录的输出路径', () => { + ensureZip() + const cliPath = join(repoRoot, 'tools', 'cocos-importer', 'bin', 'windup-cocos-import.mjs') + let exitCode = 0 + try { + execFileSync('node', [cliPath, zipPath, '--out', repoRoot, '--force'], { stdio: 'pipe' }) + } catch (err) { + exitCode = err.status + } + assert.notEqual(exitCode, 0, '仓库根目录不应成为可递归删除的输出目录') +}) + +test('CLI 拒绝没有 manifest 的 ZIP', () => { + // 造一个最小 STORED ZIP,只有一个 README,没 manifest + const fakeZip = join(__dirname, '.tmp-no-manifest.zip') + const innerName = 'README.md' + const innerData = Buffer.from('# not a windup package\n') + + // 手工拼一个 stored ZIP + const lh = Buffer.alloc(30) + const nameBuf = Buffer.from(innerName, 'utf-8') + lh.writeUInt32LE(0x04034b50, 0) + lh.writeUInt16LE(20, 4) // version + lh.writeUInt16LE(0, 6) // flags + lh.writeUInt16LE(0, 8) // method = stored + lh.writeUInt16LE(0, 10) // mtime + lh.writeUInt16LE(0, 12) // mdate + lh.writeUInt32LE(0, 14) // crc + lh.writeUInt32LE(innerData.length, 18) // csize + lh.writeUInt32LE(innerData.length, 22) // usize + lh.writeUInt16LE(nameBuf.length, 26) + lh.writeUInt16LE(0, 28) // extra + + const cdh = Buffer.alloc(46) + cdh.writeUInt32LE(0x02014b50, 0) + cdh.writeUInt16LE(20, 4) // version made by + cdh.writeUInt16LE(20, 6) // version needed + cdh.writeUInt16LE(0, 8) + cdh.writeUInt16LE(0, 10) // method + cdh.writeUInt16LE(0, 12) // mtime + cdh.writeUInt16LE(0, 14) // mdate + cdh.writeUInt32LE(0, 16) // crc + cdh.writeUInt32LE(innerData.length, 20) // csize + cdh.writeUInt32LE(innerData.length, 24) // usize + cdh.writeUInt16LE(nameBuf.length, 28) + cdh.writeUInt16LE(0, 30) // extra + cdh.writeUInt16LE(0, 32) // comment + cdh.writeUInt16LE(0, 34) // disk + cdh.writeUInt16LE(0, 36) // int attrs + cdh.writeUInt32LE(0, 38) // ext attrs + cdh.writeUInt32LE(0, 42) // local header offset + + const eocd = Buffer.alloc(22) + eocd.writeUInt32LE(0x06054b50, 0) + eocd.writeUInt16LE(0, 4) + eocd.writeUInt16LE(0, 6) + eocd.writeUInt16LE(1, 8) // entries on this disk + eocd.writeUInt16LE(1, 10) // total entries + eocd.writeUInt32LE(46, 12) // cd size + eocd.writeUInt32LE(30 + nameBuf.length + innerData.length, 16) // cd offset + eocd.writeUInt16LE(0, 20) + + writeFileSync( + fakeZip, + Buffer.concat([lh, nameBuf, innerData, cdh, eocd]), + ) + try { + const cliPath = join(repoRoot, 'tools', 'cocos-importer', 'bin', 'windup-cocos-import.mjs') + let exitCode = 0 + try { + execFileSync('node', [cliPath, fakeZip, '--out', outDir], { stdio: 'pipe' }) + } catch (err) { + exitCode = err.status + } + assert.notEqual(exitCode, 0, '没有 manifest 的 ZIP 应让 CLI 退出非 0') + } finally { + if (existsSync(fakeZip)) { + try { unlinkSync(fakeZip) } catch {} + } + } +}) diff --git a/tools/cocos-importer/test/import-core.test.mjs b/tools/cocos-importer/test/import-core.test.mjs new file mode 100644 index 00000000..1f2d3a52 --- /dev/null +++ b/tools/cocos-importer/test/import-core.test.mjs @@ -0,0 +1,195 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { execFileSync } from 'node:child_process' +import { existsSync, readFileSync } from 'node:fs' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +import { prepareImport, validatePreparedImport } from '../src/import-core.js' +import { readStoredZip } from '../src/zip-reader.js' + +const testDir = dirname(fileURLToPath(import.meta.url)) +const repoRoot = resolve(testDir, '..', '..', '..') +const frontendDir = join(repoRoot, 'frontend') +const zipPath = join(frontendDir, 'dist', 'cocos-e2e', 'windup-Hero-char-42-Ranger-outfit-7.zip') + +function fixtureZipBytes() { + if (!existsSync(zipPath)) { + execFileSync( + 'npx', + ['vitest', 'run', '--passWithNoTests', 'src/features/export-package/cocos-target.e2e.extract.test.ts'], + { cwd: frontendDir, stdio: 'pipe', shell: process.platform === 'win32' }, + ) + } + return new Uint8Array(readFileSync(zipPath)) +} + +function centralEntries(bytes) { + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength) + let eocd = bytes.length - 22 + while (eocd >= 0 && view.getUint32(eocd, true) !== 0x06054b50) eocd -= 1 + assert.ok(eocd >= 0) + const total = view.getUint16(eocd + 10, true) + let offset = view.getUint32(eocd + 16, true) + const entries = [] + for (let index = 0; index < total; index += 1) { + const nameLength = view.getUint16(offset + 28, true) + const extraLength = view.getUint16(offset + 30, true) + const commentLength = view.getUint16(offset + 32, true) + entries.push({ + centralOffset: offset, + localOffset: view.getUint32(offset + 42, true), + nameLength, + compressedSize: view.getUint32(offset + 20, true), + }) + offset += 46 + nameLength + extraLength + commentLength + } + return entries +} + +test('prepareImport 在内存中把 Windup ZIP 转换为完整 Cocos 文件集合', () => { + const prepared = prepareImport(fixtureZipBytes()) + + assert.equal(prepared.packFolder, 'windup-imports/Hero/Ranger') + assert.deepEqual(prepared.summary, { + characterName: 'Hero', + outfitName: 'Ranger', + animationCount: 1, + frameCount: 3, + fileCount: prepared.files.size, + }) + assert.ok(prepared.files.has('windup-imports/Hero/Ranger/prefabs/Hero-Ranger.prefab')) + assert.ok(prepared.files.has('windup-imports/Hero/Ranger/animations/Walk.anim')) + assert.doesNotThrow(() => validatePreparedImport(prepared)) +}) + +test('validatePreparedImport 拒绝缺失 SpriteFrame 源文件的结果', () => { + const prepared = prepareImport(fixtureZipBytes()) + const framePath = [...prepared.files.keys()].find((path) => path.endsWith('/Walk_000.png')) + assert.ok(framePath) + prepared.files.delete(framePath) + + assert.throws(() => validatePreparedImport(prepared), /IMPORT_OUTPUT_MISSING/) +}) + +test('readStoredZip 拒绝中央目录标记为符号链接的条目', () => { + const bytes = fixtureZipBytes().slice() + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength) + let eocd = bytes.length - 22 + while (eocd >= 0 && view.getUint32(eocd, true) !== 0x06054b50) eocd -= 1 + assert.ok(eocd >= 0) + const centralDirectory = view.getUint32(eocd + 16, true) + view.setUint16(centralDirectory + 4, 0x0314, true) + view.setUint32(centralDirectory + 38, 0xa1ff0000, true) + + assert.throws(() => readStoredZip(bytes), /ZIP: 包含符号链接/) +}) + +test('readStoredZip 拒绝 CRC 不匹配的损坏内容', () => { + const bytes = fixtureZipBytes().slice() + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength) + const entry = centralEntries(bytes).find((candidate) => candidate.compressedSize > 0) + assert.ok(entry) + const localNameLength = view.getUint16(entry.localOffset + 26, true) + const localExtraLength = view.getUint16(entry.localOffset + 28, true) + const dataOffset = entry.localOffset + 30 + localNameLength + localExtraLength + bytes[dataOffset] ^= 0xff + assert.throws(() => readStoredZip(bytes), /CRC/) +}) + +test('readStoredZip 拒绝中央目录和本地头文件名不一致', () => { + const bytes = fixtureZipBytes().slice() + const entry = centralEntries(bytes)[0] + bytes[entry.centralOffset + 46] ^= 1 + assert.throws(() => readStoredZip(bytes), /文件名不一致/) +}) + +test('readStoredZip 拒绝重复路径和反斜杠路径', () => { + const original = fixtureZipBytes() + const entries = centralEntries(original) + const pair = entries.flatMap((first, index) => + entries.slice(index + 1).map((second) => [first, second]), + ).find(([first, second]) => first.nameLength === second.nameLength) + assert.ok(pair) + + const duplicate = original.slice() + const [first, second] = pair + const firstName = duplicate.slice(first.centralOffset + 46, first.centralOffset + 46 + first.nameLength) + duplicate.set(firstName, second.centralOffset + 46) + duplicate.set(firstName, second.localOffset + 30) + assert.throws(() => readStoredZip(duplicate), /重复路径/) + + const backslash = original.slice() + const slashEntry = entries.find((entry) => { + const name = new TextDecoder().decode(backslash.slice(entry.centralOffset + 46, entry.centralOffset + 46 + entry.nameLength)) + return name.includes('/') + }) + assert.ok(slashEntry) + const centralSlash = backslash.indexOf('/'.charCodeAt(0), slashEntry.centralOffset + 46) + const localSlash = backslash.indexOf('/'.charCodeAt(0), slashEntry.localOffset + 30) + backslash[centralSlash] = '\\'.charCodeAt(0) + backslash[localSlash] = '\\'.charCodeAt(0) + assert.throws(() => readStoredZip(backslash), /路径不安全/) +}) + +test('readStoredZip 在解析前限制条目数、单条目和总解包大小', () => { + const bytes = fixtureZipBytes() + assert.throws(() => readStoredZip(bytes, { maxEntries: 1 }), /条目数/) + assert.throws(() => readStoredZip(bytes, { maxEntryBytes: 1 }), /单条目/) + assert.throws(() => readStoredZip(bytes, { maxTotalBytes: 1 }), /总解包大小/) +}) + +test('prepareImportFromEntries 按每次输出复制量限制重复素材引用', async () => { + const { prepareImportFromEntries } = await import('../src/import-core.js') + const frameCount = 65 + const manifest = { + schema_version: 'windup-cocos-import-1.1.0', + experimental: true, + engine: 'cocos-creator', + upstream_issue: 94, + package: { + character_id: 'c1', character_name: 'Hero', outfit_id: 'o1', outfit_name: 'Ranger', + canvas: { w: 64, h: 64 }, + }, + master: { + file: 'shared.png', anchor: { x: 0.5, y: 0.9 }, anchor_cocos: { x: 0.5, y: 0.1 }, + }, + actions: [{ + id: 'repeat', name: 'Repeat', export_name: 'Repeat', direction: 'default', fps: 12, + timing_mode: 'constant-fps', loop: true, quality_status: 'passed', + anchor: { x: 0.5, y: 0.9 }, anchor_cocos: { x: 0.5, y: 0.1 }, foot_y: 58, + frames: Array.from({ length: frameCount }, (_, index) => ({ + index, file: 'shared.png', duration_ms: null, + })), + atlas: { file: 'shared.png', cols: frameCount, rows: 1, cell: { w: 64, h: 64 } }, + }], + } + const source = new Uint8Array(4 * 1024 * 1024) + const entries = [ + { relativePath: 'targets/cocos-creator/cocos-import.json', data: new TextEncoder().encode(JSON.stringify(manifest)), size: 1, rootDir: 'fixture' }, + { relativePath: 'shared.png', data: source, size: source.length, rootDir: 'fixture' }, + { relativePath: 'frames/Repeat/shared.png', data: source, size: source.length, rootDir: 'fixture' }, + ] + + assert.throws(() => prepareImportFromEntries(entries), /IMPORT_OUTPUT_TOO_LARGE/) +}) + +test('prepareImport 为 Creator 会缓存的目录和说明文件生成稳定 meta', () => { + const first = prepareImport(fixtureZipBytes()) + const second = prepareImport(fixtureZipBytes()) + const expected = new Map([ + [`${first.packFolder}/animations.meta`, 'directory'], + [`${first.packFolder}/animations/Walk.meta`, 'directory'], + [`${first.packFolder}/prefabs.meta`, 'directory'], + [`${first.packFolder}/textures.meta`, 'directory'], + [`${first.packFolder}/cocos-import.json.meta`, 'json'], + ]) + + for (const [path, importer] of expected) { + assert.ok(first.files.has(path), path) + const firstMeta = JSON.parse(new TextDecoder().decode(first.files.get(path))) + const secondMeta = JSON.parse(new TextDecoder().decode(second.files.get(path))) + assert.equal(firstMeta.importer, importer) + assert.equal(firstMeta.uuid, secondMeta.uuid) + } +}) diff --git a/tools/cocos-importer/test/manifest-reader.test.mjs b/tools/cocos-importer/test/manifest-reader.test.mjs new file mode 100644 index 00000000..325d70a0 --- /dev/null +++ b/tools/cocos-importer/test/manifest-reader.test.mjs @@ -0,0 +1,218 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { buildManifestFromLegacyMeta, parseManifest, validateManifest } from '../src/manifest-reader.js' + +const VALID = { + schema_version: 'windup-cocos-import-1.0.0', + experimental: true, + engine: 'cocos-creator', + upstream_issue: 94, + package: { + character_id: 'c1', + character_name: 'Hero', + outfit_id: 'o1', + outfit_name: 'Ranger', + canvas: { w: 64, h: 64 }, + }, + master: { + file: 'character/master.png', + anchor: { x: 0.5, y: 0.92 }, + anchor_cocos: { x: 0.5, y: 0.08 }, + }, + actions: [ + { + id: 'walk', + name: 'Walk', + export_name: 'Walk', + direction: 'default', + fps: 8, + loop: true, + quality_status: 'passed', + anchor: { x: 0.5, y: 0.92 }, + anchor_cocos: { x: 0.5, y: 0.08 }, + foot_y: 58, + frames: [ + { index: 0, file: 'Walk_000.png', duration_ms: 125 }, + { index: 1, file: 'Walk_001.png', duration_ms: 125 }, + ], + atlas: { + file: 'atlas/Walk.png', + cols: 2, + rows: 1, + cell: { w: 64, h: 64 }, + }, + }, + ], +} + +test('parseManifest 接受合法 manifest', () => { + const m = parseManifest(JSON.stringify(VALID)) + assert.equal(m.engine, 'cocos-creator') + assert.equal(m.actions.length, 1) +}) + +test('parseManifest 接受 1.1 constant-fps 的 null 帧时长', () => { + const v11 = JSON.parse(JSON.stringify(VALID)) + v11.schema_version = 'windup-cocos-import-1.1.0' + v11.actions[0].timing_mode = 'constant-fps' + for (const frame of v11.actions[0].frames) frame.duration_ms = null + const manifest = parseManifest(JSON.stringify(v11)) + assert.equal(manifest.actions[0].timing_mode, 'constant-fps') + assert.deepEqual(manifest.actions[0].frames.map((frame) => frame.duration_ms), [null, null]) +}) + +test('parseManifest 拒绝 1.1 的非法 timing_mode', () => { + const broken = JSON.parse(JSON.stringify(VALID)) + broken.schema_version = 'windup-cocos-import-1.1.0' + broken.actions[0].timing_mode = 'rounded-milliseconds' + assert.throws(() => parseManifest(JSON.stringify(broken)), /timing_mode/) +}) + +test('parseManifest 拒绝非 JSON', () => { + assert.throws(() => parseManifest('{not json'), /不是合法 JSON/) +}) + +test('parseManifest 拒绝缺字段', () => { + const broken = JSON.parse(JSON.stringify(VALID)) + delete broken.package.character_id + assert.throws(() => parseManifest(JSON.stringify(broken)), /缺少字段.*character_id/) +}) + +test('parseManifest 拒绝错误 engine', () => { + const broken = { ...JSON.parse(JSON.stringify(VALID)), engine: 'unity' } + assert.throws(() => parseManifest(JSON.stringify(broken)), /engine/) +}) + +test('parseManifest 拒绝非 experimental', () => { + const broken = { ...JSON.parse(JSON.stringify(VALID)), experimental: false } + assert.throws(() => parseManifest(JSON.stringify(broken)), /experimental/) +}) + +test('parseManifest 拒绝错误 schema_version', () => { + const broken = { ...JSON.parse(JSON.stringify(VALID)), schema_version: 'something-else' } + assert.throws(() => parseManifest(JSON.stringify(broken)), /schema_version/) +}) + +test('parseManifest 只接受明确支持的 schema_version', () => { + for (const schema_version of ['windup-cocos-import-0.9.0', 'windup-cocos-import-1.2.0', 'windup-cocos-import-next']) { + const broken = { ...JSON.parse(JSON.stringify(VALID)), schema_version } + assert.throws(() => parseManifest(JSON.stringify(broken)), /schema_version/) + } +}) + +test('parseManifest 拒绝错误 direction', () => { + const broken = JSON.parse(JSON.stringify(VALID)) + broken.actions[0].direction = 'upside_down' + assert.throws(() => parseManifest(JSON.stringify(broken)), /direction 非法/) +}) + +test('parseManifest 拒绝错误 quality_status', () => { + const broken = JSON.parse(JSON.stringify(VALID)) + broken.actions[0].quality_status = 'maybe' + assert.throws(() => parseManifest(JSON.stringify(broken)), /quality_status 非法/) +}) + +test('parseManifest 接受只有角色母版的空 actions 包', () => { + const characterOnly = { ...JSON.parse(JSON.stringify(VALID)), actions: [] } + assert.deepEqual(parseManifest(JSON.stringify(characterOnly)).actions, []) +}) + +test('parseManifest 拒绝非正 canvas', () => { + const broken = JSON.parse(JSON.stringify(VALID)) + broken.package.canvas.w = 0 + assert.throws(() => parseManifest(JSON.stringify(broken)), /canvas\.w/) +}) + +test('parseManifest 拒绝非连续帧序号', () => { + const broken = JSON.parse(JSON.stringify(VALID)) + broken.actions[0].frames[1].index = 3 + assert.throws(() => parseManifest(JSON.stringify(broken)), /index 必须从 0 连续递增/) +}) + +test('parseManifest 拒绝不安全的素材路径', () => { + const broken = JSON.parse(JSON.stringify(VALID)) + broken.master.file = '../outside.png' + assert.throws(() => parseManifest(JSON.stringify(broken)), /master\.file.*安全的相对路径/) +}) + +test('parseManifest 拒绝 Windows 盘符、反斜杠和 NUL 素材路径', () => { + for (const file of ['C:/outside.png', 'frames\\walk.png', 'frames/evil\0.png']) { + const broken = JSON.parse(JSON.stringify(VALID)) + broken.master.file = file + assert.throws(() => parseManifest(JSON.stringify(broken)), /安全的相对路径/) + } +}) + +test('parseManifest 拒绝超出图集容量的帧', () => { + const broken = JSON.parse(JSON.stringify(VALID)) + broken.actions[0].atlas.cols = 1 + broken.actions[0].atlas.rows = 1 + assert.throws(() => parseManifest(JSON.stringify(broken)), /超出图集容量/) +}) + +test('parseManifest 限制动作数、单动作帧数和总帧数', () => { + const tooManyActions = JSON.parse(JSON.stringify(VALID)) + tooManyActions.actions = Array.from({ length: 129 }, (_, index) => ({ + ...tooManyActions.actions[0], + id: `action-${index}`, + })) + assert.throws(() => validateManifest(tooManyActions), /动作数超过限制/) + + const makeFrames = (count) => Array.from({ length: count }, (_, index) => ({ + index, + file: `frame-${index}.png`, + duration_ms: 1, + })) + const tooManyInAction = JSON.parse(JSON.stringify(VALID)) + tooManyInAction.actions[0].frames = makeFrames(2049) + tooManyInAction.actions[0].atlas = { ...tooManyInAction.actions[0].atlas, cols: 2049 } + assert.throws(() => validateManifest(tooManyInAction), /单动作帧数超过限制/) + + const tooManyTotal = JSON.parse(JSON.stringify(VALID)) + tooManyTotal.actions = [2048, 2048, 1].map((count, actionIndex) => ({ + ...tooManyTotal.actions[0], + id: `action-${actionIndex}`, + frames: makeFrames(count), + atlas: { ...tooManyTotal.actions[0].atlas, cols: count }, + })) + assert.throws(() => validateManifest(tooManyTotal), /总帧数超过限制/) +}) + +test('validateManifest 接受对象,parseManifest 接受字符串', () => { + const m = validateManifest(VALID) + assert.equal(m.engine, 'cocos-creator') + const m2 = parseManifest(JSON.stringify(VALID)) + assert.deepEqual(m, m2) +}) + +test('buildManifestFromLegacyMeta 兼容旧版 action-assets 包', () => { + const legacy = { + schema_version: '1.1.0', + stage: 'action-assets', + character: { id: 46, name: '网站看板娘', image: 'character/master.png' }, + outfit: { id: 'outfit-default', name: '默认造型' }, + canvas: { w: 256, h: 256 }, + actions: [ + { + id: 'idle', + name: '待机', + fps: 12, + loop: true, + quality_status: 'passed', + anchor: { x: 0.5, y: 0.92 }, + foot_y: 235, + frames: [{ index: 0, file: '待机_000.png' }], + atlas: { file: 'atlas/待机.png', cols: 8, rows: 4, cell: { w: 256, h: 256 } }, + }, + ], + } + const manifest = buildManifestFromLegacyMeta(legacy) + assert.equal(manifest.schema_version, 'windup-cocos-import-1.1.0') + assert.equal(manifest.package.character_id, '46') + assert.equal(manifest.package.character_name, '网站看板娘') + assert.equal(manifest.master.anchor_cocos.x, 0.5) + assert.ok(Math.abs(manifest.master.anchor_cocos.y - 0.08) < 1e-10) + assert.equal(manifest.actions[0].export_name, '待机') + assert.equal(manifest.actions[0].timing_mode, 'constant-fps') + assert.equal(manifest.actions[0].frames[0].duration_ms, null) +}) diff --git a/tools/cocos-importer/test/snapshot-uuids.mjs b/tools/cocos-importer/test/snapshot-uuids.mjs new file mode 100644 index 00000000..02fad2fa --- /dev/null +++ b/tools/cocos-importer/test/snapshot-uuids.mjs @@ -0,0 +1,55 @@ +// 快照一个目录里所有 .meta / .prefab / .anim 的关键字段(UUID 引用、SpriteFrame 子资源)。 +// 用法: node test/snapshot-uuids.mjs +import { readFileSync, readdirSync, statSync } from 'node:fs' +import { resolve, join, relative } from 'node:path' + +const dir = resolve(process.cwd(), process.argv[2]) +const out = resolve(process.cwd(), process.argv[3]) + +function walk(d) { + const out = [] + for (const n of readdirSync(d)) { + const f = join(d, n) + const s = statSync(f) + if (s.isDirectory()) out.push(...walk(f)) + else out.push(f) + } + return out +} + +function collectUuids(obj, acc, path) { + if (Array.isArray(obj)) { + obj.forEach((v, i) => collectUuids(v, acc, `${path}[${i}]`)) + return + } + if (obj && typeof obj === 'object') { + if (typeof obj.__uuid__ === 'string') acc.push({ path, uuid: obj.__uuid__ }) + for (const k of Object.keys(obj)) collectUuids(obj[k], acc, `${path}.${k}`) + } +} + +const snap = {} +for (const f of walk(dir)) { + if (!f.endsWith('.meta') && !f.endsWith('.prefab') && !f.endsWith('.anim')) continue + const rel = relative(dir, f).replace(/\\/g, '/') + try { + const j = JSON.parse(readFileSync(f, 'utf-8')) + const uuids = [] + collectUuids(j, uuids, '$') + const ownUuid = j.uuid ?? null + const subMetaUuids = j.subMetas && typeof j.subMetas === 'object' + ? Object.fromEntries( + Object.entries(j.subMetas) + .filter(([, value]) => value && typeof value.uuid === 'string') + .map(([name, value]) => [name, value.uuid]), + ) + : {} + snap[rel] = { ownUuid, subMetaUuids, uuids } + } catch (err) { + snap[rel] = { error: err.message } + } +} + +import { writeFileSync } from 'node:fs' +writeFileSync(out, JSON.stringify(snap, null, 2)) +console.log(`snap: ${Object.keys(snap).length} files → ${out}`) diff --git a/tools/cocos-importer/test/verify-output.mjs b/tools/cocos-importer/test/verify-output.mjs new file mode 100644 index 00000000..a7ca1f37 --- /dev/null +++ b/tools/cocos-importer/test/verify-output.mjs @@ -0,0 +1,367 @@ +// 硬验证 CLI 输出:逐文件解析,PNG 真的能解出像素,.meta 里有合法 uuid, +// prefab/anim 里的 __uuid__ 引用都能在 .meta 里找到。退出码 0 = 全过,非 0 = 有错。 +// +// 用法: node test/verify-output.mjs [ ] + +import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs' +import { resolve, join, relative } from 'node:path' + +const args = process.argv.slice(2) +if (args.length === 0) { + // eslint-disable-next-line no-console + console.error('用法: node test/verify-output.mjs [canvas-w canvas-h]') + process.exit(2) +} +const outDir = resolve(process.cwd(), args[0]) +const expectW = args[1] ? Number(args[1]) : 64 +const expectH = args[2] ? Number(args[2]) : 64 + +let fails = 0 +function pass(label) { + // eslint-disable-next-line no-console + console.log(` ✓ ${label}`) +} +function fail(label, why) { + fails += 1 + // eslint-disable-next-line no-console + console.log(` ✗ ${label} — ${why}`) +} + +if (!existsSync(outDir)) { + // eslint-disable-next-line no-console + console.error(`输出目录不存在: ${outDir}`) + process.exit(1) +} + +// ── PNG 解码器(只解 IHDR + 算 IDAT 解压字节数,验证尺寸/类型,不深解像素) ── +function parsePng(bytes) { + if (bytes[0] !== 0x89 || bytes[1] !== 0x50 || bytes[2] !== 0x4e || bytes[3] !== 0x47) { + throw new Error('PNG signature missing') + } + let off = 8 + let width = 0 + let height = 0 + let bitDepth = 0 + let colorType = 0 + let idatBytes = 0 + let iend = false + while (off + 12 <= bytes.length) { + const len = bytes.readUInt32BE(off) + const type = String.fromCharCode(bytes[off + 4], bytes[off + 5], bytes[off + 6], bytes[off + 7]) + if (type === 'IHDR') { + width = bytes.readUInt32BE(off + 8) + height = bytes.readUInt32BE(off + 12) + bitDepth = bytes[off + 16] + colorType = bytes[off + 17] + } else if (type === 'IDAT') { + idatBytes += len + } else if (type === 'IEND') { + iend = true + off += 8 + len + 4 + break + } + off += 8 + len + 4 + } + if (width === 0) throw new Error('no IHDR') + if (!iend) throw new Error('no IEND') + return { width, height, bitDepth, colorType, idatBytes } +} + +function walk(dir) { + const out = [] + for (const name of readdirSync(dir)) { + const full = join(dir, name) + const st = statSync(full) + if (st.isDirectory()) out.push(...walk(full)) + else out.push(full) + } + return out +} + +// eslint-disable-next-line no-console +console.log(`验证 ${outDir} (期望 canvas ${expectW}x${expectH})\n`) + +const allFiles = walk(outDir) +const pngs = allFiles.filter((f) => f.endsWith('.png')) +const metas = allFiles.filter((f) => f.endsWith('.meta')) +const prefabs = allFiles.filter((f) => f.endsWith('.prefab') && !f.endsWith('.meta')) +const animMetas = allFiles.filter((f) => f.endsWith('.anim.meta')) +const anims = allFiles.filter((f) => f.endsWith('.anim') && !f.endsWith('.meta')) +const manifestPath = join(outDir, 'cocos-import.json') +const manifest = existsSync(manifestPath) ? JSON.parse(readFileSync(manifestPath, 'utf-8')) : null +const expectedActions = new Map((manifest?.actions ?? []).map((action) => [action.export_name, action])) + +// eslint-disable-next-line no-console +console.log( + `发现 ${pngs.length} 个 PNG, ${metas.length} 个 .meta, ${prefabs.length} 个 .prefab, ${anims.length} 个 .anim (${animMetas.length} .anim.meta)`, +) + +// ── Step 0: 建 UUID → file 映射表(.meta 顶层和 SpriteFrame 子资源) ── +const uuidToFile = new Map() +for (const meta of metas) { + try { + const j = JSON.parse(readFileSync(meta, 'utf-8')) + if (typeof j.uuid === 'string' && j.uuid.length > 0) { + uuidToFile.set(j.uuid, meta.slice(0, -'.meta'.length)) + } + if (j.subMetas && typeof j.subMetas === 'object') { + for (const [name, subMeta] of Object.entries(j.subMetas)) { + if (subMeta && typeof subMeta.uuid === 'string' && subMeta.uuid.length > 0) { + const childName = subMeta.name === 'spriteFrame' || name === 'f9941' ? 'spriteFrame' : name + uuidToFile.set(subMeta.uuid, `${meta.slice(0, -'.meta'.length)}#${childName}`) + } + } + } + } catch { + // ignore + } +} +// eslint-disable-next-line no-console +console.log(`\nUUID 索引: ${uuidToFile.size} 个 uuid\n`) + +// ── Step 1: 验 PNG 全部能解 + 尺寸对得上 ── +const expectedFrameSize = `${expectW}x${expectH}` +for (const png of pngs) { + try { + const bytes = readFileSync(png) + const info = parsePng(bytes) + const key = `${info.width}x${info.height}` + const isAtlas = /[\\/]atlas\.png$/.test(png) + const validAtlasSize = isAtlas && info.width >= expectW && info.height >= expectH && info.width % expectW === 0 && info.height % expectH === 0 + if (key !== expectedFrameSize && !validAtlasSize) { + fail(relative(outDir, png), `尺寸 ${key} 不符合帧 ${expectedFrameSize} 或整格图集尺寸`) + } else if (info.colorType !== 6 || info.bitDepth !== 8) { + fail(relative(outDir, png), `颜色类型 ${info.colorType} bit ${info.bitDepth} (期望 RGBA/8)`) + } else if (info.idatBytes === 0) { + fail(relative(outDir, png), 'IDAT 字节为 0') + } else { + pass(`${relative(outDir, png)} (${key} RGBA8, IDAT ${info.idatBytes}B)`) + } + } catch (err) { + fail(relative(outDir, png), err.message) + } +} + +// ── Step 2: 验 .meta 与 PNG 尺寸对得上 + SpriteFrame 子资源 UUID 正确 ── +for (const meta of metas) { + if (meta.endsWith('.prefab.meta') || meta.endsWith('.anim.meta')) { + try { + const j = JSON.parse(readFileSync(meta, 'utf-8')) + if (typeof j.uuid !== 'string' || j.uuid.length < 8) { + fail(relative(outDir, meta), `uuid 字段缺失或过短: ${j.uuid}`) + } else { + pass(`${relative(outDir, meta)} (uuid=${j.uuid.slice(0, 8)}…)`) + } + } catch (err) { + fail(relative(outDir, meta), err.message) + } + continue + } + try { + const j = JSON.parse(readFileSync(meta, 'utf-8')) + if (j.importer !== 'image') { + pass(`${relative(outDir, meta)} (${j.importer ?? 'metadata'})`) + continue + } + const png = meta.slice(0, -'.meta'.length) + if (!existsSync(png)) { + fail(relative(outDir, meta), '找不到对应 PNG') + continue + } + if (typeof j.uuid !== 'string' || j.uuid.length < 8) { + fail(relative(outDir, meta), `uuid 字段缺失`) + } else { + const subMeta = j.subMetas?.spriteFrame ?? j.subMetas?.f9941 + const pngInfo = parsePng(readFileSync(png)) + const spriteFrameUserData = subMeta?.userData ?? subMeta + if ( + !subMeta || + typeof subMeta.uuid !== 'string' || + !subMeta.uuid.endsWith('@f9941') || + !j.subMetas?.['6c48a']?.uuid?.endsWith('@6c48a') || + spriteFrameUserData.imageUuidOrDatabaseUri !== j.subMetas['6c48a'].uuid + ) { + fail(relative(outDir, meta), '缺少 SpriteFrame 子资源 UUID 或 rawTextureUuid 链接') + } else if ( + spriteFrameUserData.rawWidth !== pngInfo.width || + spriteFrameUserData.rawHeight !== pngInfo.height + ) { + fail(relative(outDir, meta), `SpriteFrame raw size ${spriteFrameUserData.rawWidth}x${spriteFrameUserData.rawHeight} ≠ PNG ${pngInfo.width}x${pngInfo.height}`) + } else if ( + spriteFrameUserData.trimX < 0 || + spriteFrameUserData.trimY < 0 || + spriteFrameUserData.width <= 0 || + spriteFrameUserData.height <= 0 || + spriteFrameUserData.trimX + spriteFrameUserData.width > pngInfo.width || + spriteFrameUserData.trimY + spriteFrameUserData.height > pngInfo.height + ) { + fail(relative(outDir, meta), 'SpriteFrame 裁剪矩形超出 PNG 原始画布') + } else { + pass(`${relative(outDir, meta)} (texture=${j.uuid.slice(0, 8)}…, spriteFrame=${subMeta.uuid.slice(0, 8)}…, raw ${pngInfo.width}x${pngInfo.height})`) + } + } + } catch (err) { + fail(relative(outDir, meta), err.message) + } +} + +// ── Step 3: 验 .anim(真 AnimationClip JSON)结构 + uuid 引用链 ── +for (const a of anims) { + try { + const j = JSON.parse(readFileSync(a, 'utf-8')) + if (j.__type__ !== 'cc.AnimationClip') { + fail(relative(outDir, a), `__type__=${j.__type__}`) + continue + } + if (j.wrapMode !== 1 && j.wrapMode !== 2) { + fail(relative(outDir, a), `wrapMode ${j.wrapMode}`) + continue + } + const objectTrack = j._tracks?.find((track) => track?.__type__ === 'cc.animation.ObjectTrack') + const paths = objectTrack?._binding?.path?._paths + const curve = objectTrack?._channel?._curve + const times = curve?._times + const frameRefs = curve?._values + const expectedAction = expectedActions.get(j._name) + if ( + j._duration <= 0 || + !Array.isArray(paths) || + paths[0]?.__type__ !== 'cc.animation.ComponentPath' || + paths[0]?.component !== 'cc.Sprite' || + paths[1] !== 'spriteFrame' || + curve?.__type__ !== 'cc.ObjectCurve' || + !Array.isArray(times) || + !Array.isArray(frameRefs) || + frameRefs.length < 1 || + times.length !== frameRefs.length || + times.some((time, index) => index > 0 && time <= times[index - 1]) + ) { + fail(relative(outDir, a), 'Creator 3.8 SpriteFrame 对象轨道无效') + continue + } + if (expectedAction) { + const expectedWrapMode = expectedAction.loop ? 2 : 1 + if (frameRefs.length !== expectedAction.frames.length) { + fail(relative(outDir, a), `动画关键帧 ${frameRefs.length} ≠ manifest ${expectedAction.frames.length}`) + } + if (j.sample !== expectedAction.fps) { + fail(relative(outDir, a), `sample ${j.sample} ≠ manifest fps ${expectedAction.fps}`) + } + if (j.wrapMode !== expectedWrapMode) { + fail(relative(outDir, a), `wrapMode ${j.wrapMode} ≠ manifest loop=${expectedAction.loop}`) + } + } + pass(`${relative(outDir, a)} (AnimationClip ${j._duration}s @ ${j.sample}fps, wrapMode=${j.wrapMode}, ${frameRefs.length} keys)`) + // 检查每个 frame key 的 __uuid__ 在 UUID 索引里能找到 + for (const frameRef of frameRefs) { + const ref = String(frameRef?.__uuid__ ?? '') + if (!ref) { + fail(relative(outDir, a), 'frame key 缺 __uuid__') + } else if (ref.startsWith('frame:')) { + // 兜底伪引用(理论上不应出现):检查路径存在 + const p = ref.replace(/^frame:/, '') + if (!existsSync(join(outDir, p))) { + fail(relative(outDir, a), `frame: 兜底引用 ${p} 不在输出目录`) + } + } else if (!uuidToFile.has(ref)) { + fail(relative(outDir, a), `__uuid__ ${ref.slice(0, 8)}… 在 .meta 索引中找不到`) + } else if (!uuidToFile.get(ref).includes('#spriteFrame')) { + fail(relative(outDir, a), `__uuid__ ${ref.slice(0, 8)}… 不是 SpriteFrame 子资源`) + } else { + pass(`${relative(outDir, a)} frame → uuid ${ref.slice(0, 8)}… (${relative(outDir, uuidToFile.get(ref))})`) + } + } + } catch (err) { + fail(relative(outDir, a), err.message) + } +} + +// ── Step 4: 验 .prefab 结构 + sprite/anim uuid 引用链 ── +for (const pf of prefabs) { + try { + const j = JSON.parse(readFileSync(pf, 'utf-8')) + const prefabAsset = Array.isArray(j) ? j[0] : j + if (prefabAsset?.__type__ !== 'cc.Prefab') { + fail(relative(outDir, pf), `__type__=${prefabAsset?.__type__}`) + continue + } + const objects = Array.isArray(j) ? j : null + const resolveRef = (value) => { + if (!objects || !value || !Number.isInteger(value.__id__)) return value + return objects[value.__id__] + } + const root = resolveRef(prefabAsset.data) + pass(`${relative(outDir, pf)} (cc.Prefab, _name=${prefabAsset._name})`) + const components = (root?._components ?? []).map(resolveRef) + const uiTransform = components.find((component) => component?.__type__ === 'cc.UITransform') + const sprite = components.find((component) => component?.__type__ === 'cc.Sprite') + const animation = components.find((component) => component?.__type__ === 'cc.Animation') + if (uiTransform?._contentSize?.width !== expectW || uiTransform?._contentSize?.height !== expectH) { + fail(relative(outDir, pf), `UITransform 不是稳定的 ${expectW}x${expectH}`) + } else { + pass(`${relative(outDir, pf)} UITransform=${expectW}x${expectH}`) + } + if (sprite?._sizeMode !== 0 || sprite?._isTrimmedMode !== false) { + fail(relative(outDir, pf), 'Sprite 必须使用 CUSTOM size 且关闭 trimmed mode') + } else { + pass(`${relative(outDir, pf)} Sprite=CUSTOM, trimmed=false`) + } + if (!animation || animation.playOnLoad !== true || animation._clips?.length !== expectedActions.size) { + fail(relative(outDir, pf), `Animation 组件未包含 ${expectedActions.size} 个可自动播放 clip`) + } else { + pass(`${relative(outDir, pf)} Animation=${animation._clips.length} clips, playOnLoad=true`) + } + // 找所有 _spriteFrame.__uuid__ + const allSpriteRefs = [] + function walkNode(nodeValue) { + const n = resolveRef(nodeValue) + if (!n) return + if (Array.isArray(n._components)) { + for (const componentRef of n._components) { + const c = resolveRef(componentRef) + if (c?._spriteFrame?.__uuid__) allSpriteRefs.push(c._spriteFrame.__uuid__) + } + } + if (Array.isArray(n._children)) for (const c of n._children) walkNode(c) + } + walkNode(root) + for (const ref of allSpriteRefs) { + if (!uuidToFile.has(ref)) { + fail(relative(outDir, pf), `sprite __uuid__ ${ref.slice(0, 8)}… 在 .meta 索引中找不到`) + } else if (!uuidToFile.get(ref).includes('#spriteFrame')) { + fail(relative(outDir, pf), `sprite __uuid__ ${ref.slice(0, 8)}… 不是 SpriteFrame 子资源`) + } else { + pass(`${relative(outDir, pf)} sprite → uuid ${ref.slice(0, 8)}… (${relative(outDir, uuidToFile.get(ref))})`) + } + } + // 找所有 _clips[].__uuid__ + const allAnimRefs = [] + function walkComps(nodeValue) { + const n = resolveRef(nodeValue) + if (!n) return + if (Array.isArray(n._components)) { + for (const componentRef of n._components) { + const c = resolveRef(componentRef) + if (c?.__type__ === 'cc.Animation' && Array.isArray(c._clips)) { + for (const cl of c._clips) if (cl?.__uuid__) allAnimRefs.push(cl.__uuid__) + } + } + } + if (Array.isArray(n._children)) for (const child of n._children) walkComps(child) + } + walkComps(root) + for (const ref of allAnimRefs) { + if (!uuidToFile.has(ref)) { + fail(relative(outDir, pf), `anim __uuid__ ${ref.slice(0, 8)}… 在 .meta 索引中找不到`) + } else { + pass(`${relative(outDir, pf)} anim → uuid ${ref.slice(0, 8)}… (${relative(outDir, uuidToFile.get(ref))})`) + } + } + } catch (err) { + fail(relative(outDir, pf), err.message) + } +} + +// eslint-disable-next-line no-console +console.log(`\n=== 结果: ${fails === 0 ? '全过' : `${fails} 项失败`} ===`) +process.exit(fails === 0 ? 0 : 1) From 8c25b49c9e19c51b2f944d72b6dac3058b6f5e9b Mon Sep 17 00:00:00 2001 From: xyh202131 <246811510+xyh202131@users.noreply.github.com> Date: Thu, 20 Aug 2026 20:40:00 +0800 Subject: [PATCH 13/15] test(export): cover Cocos import edge cases --- .../cocos-bridge-client.test.ts | 83 +++++++++ .../cocos-one-click.defaults.test.ts | 83 +++++++++ .../export-package/cocos-one-click.test.ts | 131 ++++++++++++- .../export-package/cocos-target.test.ts | 36 ++++ .../export-panel.defaults.test.tsx | 125 +++++++++++++ .../export-package/export-panel.test.tsx | 173 ++++++++++++++++++ 6 files changed, 630 insertions(+), 1 deletion(-) create mode 100644 frontend/src/features/export-package/cocos-one-click.defaults.test.ts create mode 100644 frontend/src/features/export-package/export-panel.defaults.test.tsx diff --git a/frontend/src/features/export-package/cocos-bridge-client.test.ts b/frontend/src/features/export-package/cocos-bridge-client.test.ts index 30c27316..886a6442 100644 --- a/frontend/src/features/export-package/cocos-bridge-client.test.ts +++ b/frontend/src/features/export-package/cocos-bridge-client.test.ts @@ -82,6 +82,23 @@ describe('CocosBridgeClient', () => { }) }) + it('accepts a paired Creator health response without a project name', async () => { + const fetch: typeof globalThis.fetch = async () => + json({ + protocol: COCOS_BRIDGE_PROTOCOL, + creatorVersion: '3.8.8', + projectName: null, + projectOpen: false, + paired: true, + }) + + await expect(new CocosBridgeClient(options(fetch)).health()).resolves.toMatchObject({ + projectName: null, + projectOpen: false, + paired: true, + }) + }) + it('stores the issued token after a valid one-time pairing code', async () => { const storage = new MemoryStorage() const fetch: typeof globalThis.fetch = async (input, init) => { @@ -245,6 +262,72 @@ describe('CocosBridgeClient', () => { await expect(client.getJob('job-3')).rejects.toMatchObject({ code: 'IMPORT_FAILED' }) }) + it.each([ + { + name: 'non-object health body', + body: [] as unknown, + message: 'health 返回格式错误', + }, + { + name: 'empty Creator version', + body: { + protocol: COCOS_BRIDGE_PROTOCOL, + creatorVersion: '', + projectName: 'Game', + projectOpen: true, + paired: true, + }, + message: 'health.creatorVersion 必须是非空字符串', + }, + { + name: 'non-boolean pairing state', + body: { protocol: COCOS_BRIDGE_PROTOCOL, paired: 'yes' }, + message: 'health.paired 必须是布尔值', + }, + ])('rejects $name at the health boundary', async ({ body, message }) => { + const client = new CocosBridgeClient(options(async () => json(body))) + + await expect(client.health()).rejects.toThrow(message) + }) + + it('rejects non-numeric import totals instead of accepting a corrupt result', async () => { + const storage = new MemoryStorage() + storage.setItem('windup:cocos-bridge:token:v1', 'token') + const client = new CocosBridgeClient( + options( + async () => + json({ + protocol: COCOS_BRIDGE_PROTOCOL, + jobId: 'job-bad-total', + status: 'completed', + phase: 'verifying', + result: { + projectName: 'Game', + dbUrl: 'db://assets/result.prefab', + animationCount: null, + frameCount: 64, + }, + }), + storage, + ), + ) + + await expect(client.getJob('job-bad-total')).rejects.toThrow( + 'job.result.animationCount 必须是数字', + ) + }) + + it.each([{ message: '' }, null])( + 'uses a stable fallback when an HTTP error has no usable message: %j', + async (body) => { + const storage = new MemoryStorage() + storage.setItem('windup:cocos-bridge:token:v1', 'token') + const client = new CocosBridgeClient(options(async () => json(body, 500), storage)) + + await expect(client.getJob('job-1')).rejects.toThrow('Cocos 导入失败') + }, + ) + it('rejects a bridge using another protocol version', async () => { const client = new CocosBridgeClient( options(async () => diff --git a/frontend/src/features/export-package/cocos-one-click.defaults.test.ts b/frontend/src/features/export-package/cocos-one-click.defaults.test.ts new file mode 100644 index 00000000..b7d49b5c --- /dev/null +++ b/frontend/src/features/export-package/cocos-one-click.defaults.test.ts @@ -0,0 +1,83 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import type { AssetExportResult } from './asset-export' +import type { CocosBridgeApi, CocosOneClickPhase } from './cocos-one-click' +import { importIntoCocos } from './cocos-one-click' +import type { ExportPackageModel } from './model' + +const { exportGameAssetsMock } = vi.hoisted(() => ({ exportGameAssetsMock: vi.fn() })) + +vi.mock('./asset-export', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, exportGameAssets: exportGameAssetsMock } +}) + +const model: ExportPackageModel = { + stage: 'character', + characterId: 'hero', + characterName: 'Hero', + characterImageUrl: 'memory://hero.png', + outfitId: 'default', + outfitName: 'Default', + canvas: { width: 256, height: 256 }, + source: null, + firstFrames: [], + actions: [], + playtest: null, +} + +const packageResult: AssetExportResult = { + blob: new Blob(['zip'], { type: 'application/zip' }), + filename: 'windup-Hero.zip', +} + +const bridge: CocosBridgeApi = { + health: async () => ({ + protocol: 'windup-cocos-bridge/1.0.0', + creatorVersion: '3.8.8', + projectName: 'Game', + projectOpen: true, + paired: true, + }), + submit: async () => ({ jobId: 'job-default-exporter' }), + getJob: async () => ({ + protocol: 'windup-cocos-bridge/1.0.0', + jobId: 'job-default-exporter', + status: 'completed', + phase: 'verifying', + result: { + projectName: 'Game', + dbUrl: 'db://assets/windup-imports/Hero.prefab', + animationCount: 0, + frameCount: 0, + }, + }), +} + +beforeEach(() => { + exportGameAssetsMock.mockReset() + exportGameAssetsMock.mockImplementation( + async (_model: ExportPackageModel, options: { onPhase?: (phase: 'packing') => void }) => { + options.onPhase?.('packing') + return packageResult + }, + ) +}) + +describe('importIntoCocos defaults', () => { + it('uses the Cocos target when no custom package exporter is supplied', async () => { + const phases: CocosOneClickPhase[] = [] + + await importIntoCocos(model, bridge, (phase) => phases.push(phase), { + pollDelay: async () => undefined, + createRequestId: () => '11111111-1111-4111-8111-111111111111', + }) + + expect(exportGameAssetsMock).toHaveBeenCalledTimes(1) + const options = exportGameAssetsMock.mock.calls[0]?.[1] as { + targets: Array<{ id: string }> + } + expect(options.targets.map((target) => target.id)).toEqual(['cocos-creator']) + expect(phases).toEqual(['detecting', 'packing', 'uploading', 'verifying']) + }) +}) diff --git a/frontend/src/features/export-package/cocos-one-click.test.ts b/frontend/src/features/export-package/cocos-one-click.test.ts index 094888a6..d7ed7602 100644 --- a/frontend/src/features/export-package/cocos-one-click.test.ts +++ b/frontend/src/features/export-package/cocos-one-click.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import type { AssetExportResult } from './asset-export' import type { CocosBridgeApi, CocosImportCache, CocosOneClickPhase } from './cocos-one-click' @@ -57,6 +57,10 @@ const packageResult: AssetExportResult = { filename: 'windup-Hero.zip', } +afterEach(() => { + vi.useRealTimers() +}) + describe('importIntoCocos', () => { it('checks Creator, exports once, uploads and returns the completed result', async () => { const phases: CocosOneClickPhase[] = [] @@ -205,6 +209,131 @@ describe('importIntoCocos', () => { ).resolves.toMatchObject({ projectName: 'Game' }) }) + it('accepts a supported Creator prerelease suffix', async () => { + await expect( + importIntoCocos( + model(), + bridge({ + health: async () => ({ + protocol: 'windup-cocos-bridge/1.0.0', + creatorVersion: '3.8.8-beta.1', + projectName: 'Game', + projectOpen: true, + paired: true, + }), + }), + () => undefined, + { exporter: async () => packageResult, pollDelay: async () => undefined }, + ), + ).resolves.toMatchObject({ projectName: 'Game' }) + }) + + it.each([null, '4.8.8', '3.7.8'])('rejects incompatible Creator version %s', async (version) => { + await expect( + importIntoCocos( + model(), + bridge({ + health: async () => ({ + protocol: 'windup-cocos-bridge/1.0.0', + creatorVersion: version, + projectName: 'Game', + projectOpen: true, + paired: true, + }), + }), + ), + ).rejects.toMatchObject({ code: 'VERSION_UNSUPPORTED' }) + }) + + it('reports every plugin phase while a job advances', async () => { + const phases: CocosOneClickPhase[] = [] + const pendingPhases = ['queued', 'validating', 'converting', 'writing', 'refreshing'] as const + let call = 0 + const api = bridge({ + getJob: async () => { + const phase = pendingPhases[call] + call += 1 + if (phase === undefined) return completedJob() + return { + protocol: 'windup-cocos-bridge/1.0.0', + jobId: 'job-1', + status: phase === 'queued' ? 'queued' : 'running', + phase, + } + }, + }) + + await importIntoCocos(model(), api, (phase) => phases.push(phase), { + exporter: async () => packageResult, + pollDelay: async () => undefined, + }) + + expect(phases).toEqual([ + 'detecting', + 'uploading', + 'queued', + 'validating', + 'converting', + 'writing', + 'refreshing', + 'verifying', + ]) + }) + + it('rejects a completed job that omits its import result', async () => { + const completedWithoutResult = completedJob() + delete completedWithoutResult.result + + await expect( + importIntoCocos( + model(), + bridge({ getJob: async () => completedWithoutResult }), + () => undefined, + { exporter: async () => packageResult, pollDelay: async () => undefined }, + ), + ).rejects.toThrow('Creator 插件未返回导入结果') + }) + + it('uses stable failure details when a failed job omits its error payload', async () => { + const failedWithoutError = completedJob() + failedWithoutError.status = 'failed' + failedWithoutError.phase = 'converting' + delete failedWithoutError.result + + await expect( + importIntoCocos( + model(), + bridge({ getJob: async () => failedWithoutError }), + () => undefined, + { exporter: async () => packageResult, pollDelay: async () => undefined }, + ), + ).rejects.toMatchObject({ + message: 'Cocos 导入失败', + jobCode: 'IMPORT_FAILED', + phase: 'converting', + rolledBack: false, + }) + }) + + it('uses the default polling delay when no custom delay is supplied', async () => { + vi.useFakeTimers() + const running = completedJob() + running.status = 'running' + running.phase = 'queued' + delete running.result + + const promise = importIntoCocos( + model(), + bridge({ getJob: async () => running }), + () => undefined, + { exporter: async () => packageResult, maxPolls: 1 }, + ) + const rejection = expect(promise).rejects.toThrow('等待 Cocos 导入完成超时') + await vi.advanceTimersByTimeAsync(500) + + await rejection + }) + it('reports the plugin failure and whether the previous asset was restored', async () => { const failed = completedJob() failed.status = 'failed' diff --git a/frontend/src/features/export-package/cocos-target.test.ts b/frontend/src/features/export-package/cocos-target.test.ts index 44b3bd92..1967246f 100644 --- a/frontend/src/features/export-package/cocos-target.test.ts +++ b/frontend/src/features/export-package/cocos-target.test.ts @@ -275,4 +275,40 @@ describe('cocos-target', () => { 'Walk-north', ]) }) + + it('rejects metadata and plan action-count drift before creating a manifest', async () => { + const context = buildContext() + + await expect(cocosCreatorTarget.createFiles({ ...context, plan: [] })).rejects.toThrow( + 'meta.json 动作数量 1 与 plan 数量 0 不一致', + ) + }) + + it('rejects a sparse plan entry instead of producing a misaligned action', async () => { + const context = buildContext() + const sparsePlan = new Array(1) + + await expect(cocosCreatorTarget.createFiles({ ...context, plan: sparsePlan })).rejects.toThrow( + 'meta.json 缺少第 0 个 plan 动作', + ) + }) + + it('uses a null duration when metadata contains a frame absent from the plan', async () => { + const context = buildContext() + const metadata = { + ...context.metadata, + actions: context.metadata.actions.map((action) => ({ + ...action, + frames: [...action.frames, { index: 4, file: 'Walk-default_004.png' }], + })), + } + const files = await cocosCreatorTarget.createFiles({ ...context, metadata }) + const manifest = JSON.parse(String(files[0]?.data)) + + expect(manifest.actions[0].frames[4]).toEqual({ + index: 4, + file: 'Walk-default_004.png', + duration_ms: null, + }) + }) }) diff --git a/frontend/src/features/export-package/export-panel.defaults.test.tsx b/frontend/src/features/export-package/export-panel.defaults.test.tsx new file mode 100644 index 00000000..511cf21b --- /dev/null +++ b/frontend/src/features/export-package/export-panel.defaults.test.tsx @@ -0,0 +1,125 @@ +/** @vitest-environment jsdom */ +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import type { ExportPackageModel } from './model' + +const { exportGameAssetsMock, importIntoCocosMock, pairMock } = vi.hoisted(() => ({ + exportGameAssetsMock: vi.fn(), + importIntoCocosMock: vi.fn(), + pairMock: vi.fn(), +})) + +vi.mock('./asset-export', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, exportGameAssets: exportGameAssetsMock } +}) + +vi.mock('./cocos-one-click', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, importIntoCocos: importIntoCocosMock } +}) + +vi.mock('./cocos-bridge-client', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + CocosBridgeClient: class { + pair(code: string) { + return pairMock(code) + } + }, + } +}) + +import { CocosBridgeError } from './cocos-bridge-client' +import { ExportPanel } from './export-panel' + +const model: ExportPackageModel = { + stage: 'character', + characterId: 'hero', + characterName: 'Hero', + characterImageUrl: 'memory://hero.png', + outfitId: 'default', + outfitName: 'Default', + canvas: { width: 256, height: 256 }, + source: null, + firstFrames: [], + actions: [], + playtest: null, +} + +const importResult = { + projectName: 'DefaultGame', + dbUrl: 'db://assets/windup-imports/Hero.prefab', + animationCount: 0, + frameCount: 0, +} + +beforeEach(() => { + exportGameAssetsMock.mockReset() + importIntoCocosMock.mockReset() + pairMock.mockReset() + Object.defineProperty(URL, 'createObjectURL', { + configurable: true, + value: vi.fn(() => 'blob:cocos-default'), + }) + Object.defineProperty(URL, 'revokeObjectURL', { configurable: true, value: vi.fn() }) + vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => undefined) +}) + +afterEach(() => { + cleanup() + vi.restoreAllMocks() +}) + +describe('ExportPanel Cocos defaults', () => { + it('uses the default Cocos package exporter for the download fallback', async () => { + exportGameAssetsMock.mockResolvedValue({ + blob: new Blob(['zip'], { type: 'application/zip' }), + filename: 'windup-cocos.zip', + }) + + render() + fireEvent.click(screen.getByRole('button', { name: '下载 Cocos 包' })) + + await waitFor(() => expect(exportGameAssetsMock).toHaveBeenCalledTimes(1)) + const options = exportGameAssetsMock.mock.calls[0]?.[1] as { + targets: Array<{ id: string }> + } + expect(options.targets.map((target) => target.id)).toEqual(['cocos-creator']) + }) + + it('uses the default bridge importer when no importer is injected', async () => { + importIntoCocosMock.mockResolvedValue(importResult) + + render() + fireEvent.click(screen.getByRole('button', { name: '一键导入 Cocos' })) + + expect(await screen.findByText('已导入到当前 Cocos 工程')).toBeTruthy() + expect(importIntoCocosMock).toHaveBeenCalledWith( + model, + expect.anything(), + expect.any(Function), + expect.objectContaining({ cache: {} }), + ) + }) + + it('uses the default bridge pairer before retrying the import', async () => { + importIntoCocosMock + .mockRejectedValueOnce(new CocosBridgeError('PAIRING_REQUIRED', '请先配对')) + .mockResolvedValueOnce(importResult) + pairMock.mockResolvedValue(undefined) + + render() + fireEvent.click(screen.getByRole('button', { name: '一键导入 Cocos' })) + fireEvent.change(await screen.findByLabelText('Creator 连接码'), { + target: { value: '123456' }, + }) + fireEvent.click(screen.getByRole('button', { name: '连接并导入' })) + + await waitFor(() => expect(pairMock).toHaveBeenCalledWith('123456')) + expect(await screen.findByText('已导入到当前 Cocos 工程')).toBeTruthy() + expect(importIntoCocosMock).toHaveBeenCalledTimes(2) + }) +}) diff --git a/frontend/src/features/export-package/export-panel.test.tsx b/frontend/src/features/export-package/export-panel.test.tsx index 963dc68f..03797d65 100644 --- a/frontend/src/features/export-package/export-panel.test.tsx +++ b/frontend/src/features/export-package/export-panel.test.tsx @@ -209,6 +209,45 @@ describe('ExportPanel', () => { expect(importer).toHaveBeenCalledTimes(1) }) + it('一键导入进行中时忽略绕过禁用属性的重复触发', async () => { + let resolveImport: (result: { + projectName: string + dbUrl: string + animationCount: number + frameCount: number + }) => void = () => { + throw new Error('import promise was not initialized') + } + const importer = vi.fn( + () => + new Promise<{ + projectName: string + dbUrl: string + animationCount: number + frameCount: number + }>((resolve) => { + resolveImport = resolve + }), + ) + + render() + const button = screen.getByRole('button', { name: '一键导入 Cocos' }) as HTMLButtonElement + fireEvent.click(button) + expect(button.disabled).toBe(true) + + button.disabled = false + fireEvent.click(button) + expect(importer).toHaveBeenCalledTimes(1) + + resolveImport({ + projectName: 'CocosGame', + dbUrl: 'db://assets/windup-imports/Aster.prefab', + animationCount: 1, + frameCount: 8, + }) + expect(await screen.findByText('已导入到当前 Cocos 工程')).toBeTruthy() + }) + it('首次未配对时显示连接码输入框,配对后继续同一次导入', async () => { const importer = vi .fn() @@ -232,6 +271,37 @@ describe('ExportPanel', () => { expect(importer).toHaveBeenCalledTimes(2) }) + it('连接码不是六位数字时留在配对界面且不调用插件', async () => { + const importer = vi.fn().mockRejectedValue(new CocosBridgeError('PAIRING_REQUIRED', '请先配对')) + const pairer = vi.fn().mockResolvedValue(undefined) + + render() + fireEvent.click(screen.getByRole('button', { name: '一键导入 Cocos' })) + const input = await screen.findByLabelText('Creator 连接码') + fireEvent.change(input, { target: { value: '12a3' } }) + fireEvent.click(screen.getByRole('button', { name: '连接并导入' })) + + expect(await screen.findByText('请输入 Creator 显示的 6 位连接码')).toBeTruthy() + expect((input as HTMLInputElement).value).toBe('123') + expect(pairer).not.toHaveBeenCalled() + }) + + it('配对请求失败时显示插件返回的具体错误', async () => { + const importer = vi.fn().mockRejectedValue(new CocosBridgeError('PAIRING_REQUIRED', '请先配对')) + const pairer = vi.fn().mockRejectedValue(new Error('连接码已过期')) + + render() + fireEvent.click(screen.getByRole('button', { name: '一键导入 Cocos' })) + fireEvent.change(await screen.findByLabelText('Creator 连接码'), { + target: { value: '654321' }, + }) + fireEvent.click(screen.getByRole('button', { name: '连接并导入' })) + + expect(await screen.findByText('导入失败:连接码已过期')).toBeTruthy() + expect(pairer).toHaveBeenCalledWith('654321') + expect(importer).toHaveBeenCalledTimes(1) + }) + it('插件不可用时保留明确错误和 Cocos 包下载降级入口', async () => { const importer = vi .fn() @@ -260,6 +330,65 @@ describe('ExportPanel', () => { expect(screen.getByText(/回滚:未完成,请检查工程资产/)).toBeTruthy() }) + it('导入失败时区分已回滚和无需回滚', async () => { + const rolledBack = new CocosBridgeError('IMPORT_FAILED', '资源刷新失败', undefined, { + jobCode: 'IMPORT_REFRESH_FAILED', + phase: 'refreshing', + rolledBack: true, + }) + const importer = vi.fn().mockRejectedValueOnce(rolledBack) + + render() + fireEvent.click(screen.getByRole('button', { name: '一键导入 Cocos' })) + expect(await screen.findByText(/回滚:已完成/)).toBeTruthy() + + cleanup() + const noRollback = new CocosBridgeError('IMPORT_FAILED', '校验失败', undefined, { + jobCode: 'IMPORT_VALIDATION_FAILED', + phase: 'validating', + rolledBack: false, + }) + const secondImporter = vi.fn().mockRejectedValue(noRollback) + render() + fireEvent.click(screen.getByRole('button', { name: '一键导入 Cocos' })) + expect(await screen.findByText(/回滚:未执行/)).toBeTruthy() + }) + + it('错误码存在但阶段缺失时使用排队阶段兜底', async () => { + const error = new CocosBridgeError('IMPORT_FAILED', '任务状态缺失', undefined, { + jobCode: 'IMPORT_STATUS_INVALID', + phase: 'queued', + rolledBack: false, + }) + Object.defineProperty(error, 'phase', { value: undefined }) + const importer = vi.fn().mockRejectedValue(error) + + render() + fireEvent.click(screen.getByRole('button', { name: '一键导入 Cocos' })) + + expect(await screen.findByText(/阶段:Cocos Creator 正在排队/)).toBeTruthy() + }) + + it('导入器抛出非 Error 值时展示通用错误', async () => { + const importer = vi.fn().mockRejectedValue({ reason: 'offline' }) + + render() + fireEvent.click(screen.getByRole('button', { name: '一键导入 Cocos' })) + + expect(await screen.findByText('导入失败:未知错误')).toBeTruthy() + }) + + it('Cocos 下载降级失败后显示重试文案和具体原因', async () => { + const cocosExporter = vi.fn().mockRejectedValue(new Error('图集打包失败')) + + render() + fireEvent.click(screen.getByRole('button', { name: '下载 Cocos 包' })) + + const retry = await screen.findByRole('button', { name: '重新导出 Cocos 包' }) + expect(retry.getAttribute('title')).toBe('图集打包失败') + expect(screen.getByText('导出失败:图集打包失败')).toBeTruthy() + }) + it('可关闭 Cocos 导出入口而不影响通用导出', () => { render() @@ -316,6 +445,42 @@ describe('ExportPanel', () => { expect(cocosExporter).toHaveBeenCalledWith(model, expect.any(Function)) }) + it('紧凑导出按钮可直接完成 Cocos 一键导入', async () => { + const importer = vi.fn().mockResolvedValue({ + projectName: 'CompactGame', + dbUrl: 'db://assets/windup-imports/Aster.prefab', + animationCount: 1, + frameCount: 8, + }) + + render() + fireEvent.click(screen.getByRole('button', { name: '一键导入 Cocos' })) + + expect(await screen.findByText('CompactGame · 1 个动作,8 帧')).toBeTruthy() + expect(importer).toHaveBeenCalledWith(model, expect.any(Function)) + }) + + it('紧凑 Cocos 一键导入失败后显示重试文案', async () => { + const importer = vi.fn().mockRejectedValue(new Error('导入连接中断')) + + render() + fireEvent.click(screen.getByRole('button', { name: '一键导入 Cocos' })) + + expect(await screen.findByRole('button', { name: '重新导入 Cocos' })).toBeTruthy() + expect(screen.getByText('导入失败:导入连接中断')).toBeTruthy() + }) + + it('紧凑 Cocos 下载失败时显示重试入口、标题和独立警告', async () => { + const cocosExporter = vi.fn().mockRejectedValue(new Error('Cocos 图集生成失败')) + + render() + fireEvent.click(screen.getByRole('button', { name: '下载 Cocos 包' })) + + const retry = await screen.findByRole('button', { name: '重新导出 Cocos 包' }) + expect(retry.getAttribute('title')).toBe('Cocos 图集生成失败') + expect(screen.getByText('Cocos 导出失败:Cocos 图集生成失败')).toBeTruthy() + }) + it('紧凑导出按钮失败后显示可访问的具体错误', async () => { const exporter = vi.fn().mockRejectedValue(new Error('图片下载失败')) @@ -335,4 +500,12 @@ describe('ExportPanel', () => { const button = screen.getByRole('button', { name: '导出资产包' }) expect(button.className).toContain('rounded-full') }) + + it('紧凑导出按钮可单独关闭 Cocos 入口', () => { + render() + + expect(screen.queryByRole('button', { name: '一键导入 Cocos' })).toBeNull() + expect(screen.queryByRole('button', { name: '下载 Cocos 包' })).toBeNull() + expect(screen.getByRole('button', { name: '导出完整动作资产' })).toBeTruthy() + }) }) From 0577c89aadeeb13efc697df278940fe41576f6c7 Mon Sep 17 00:00:00 2001 From: xyh202131 <246811510+xyh202131@users.noreply.github.com> Date: Fri, 21 Aug 2026 11:11:51 +0800 Subject: [PATCH 14/15] =?UTF-8?q?fix(cocos):=20=E6=81=A2=E5=A4=8D=E6=97=A7?= =?UTF-8?q?=E7=89=88=E5=AF=BC=E5=87=BA=E8=B5=84=E6=BA=90=E8=B7=AF=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dist/windup-cocos-importer.zip | Bin 74598 -> 75310 bytes tools/cocos-importer/src/manifest-reader.js | 23 ++++++++- .../cocos-importer/test/import-core.test.mjs | 47 ++++++++++++++++-- .../test/manifest-reader.test.mjs | 44 ++++++++++++++++ 4 files changed, 110 insertions(+), 4 deletions(-) diff --git a/tools/cocos-importer/dist/windup-cocos-importer.zip b/tools/cocos-importer/dist/windup-cocos-importer.zip index 30d4219eb042301528839b03c372053c4b634c36..bfc29ffb66551bd88564574795363f13818d8a8c 100644 GIT binary patch delta 816 zcma))!E4k&6vi{#Z4ZG$r5?1>`m)F-u}ijsAV`;0(1W;&;BnLHG@Zn$>10hN?XFf9 z550&7We;=i#iJ-o!J9Wvf>ac59zA&RALzl^8Fg0|!Gw^^d+&SmyK)8n&-F?gKbJ#oDMWqufe8>KEaSTCHOzxMPy^m+ktEbHFQhSnTCE0l##727TWSF6 zP+e6{{HoJjt@^&dw6f@SeXqr$Q`OufZ4x-DKs9ic=sd7Ij^!r}!O1hc7L$e=^CpQl z3FBIzT-3ehnHATCl<+)dBlcw2lpVq(1xH&~BtZHN)2?Ka>9O=CmQL>M{F+Ixt-(0S z+6D=@4KjH#O>xJgnR+{_M(!=!bINj}ypQbl7baD%F?Gi{X;LGIZu#qre^MIiH6?)G zoc>KpiN;7RNL0hj02RQ)0WOWPS3hu1nlcdPOr0he{|Vyh5p=ZoT8HiPEGZV8<( w?9Pf;C3L0mW?meY(514wfQ%Ln?&64e8(G3`T%DXUExTr i=I>IB)9=VKN>0D0%BaPZC_SA^jZuzmy&NM00|Nk`2`@kZ diff --git a/tools/cocos-importer/src/manifest-reader.js b/tools/cocos-importer/src/manifest-reader.js index e8e9f8e1..12be693e 100644 --- a/tools/cocos-importer/src/manifest-reader.js +++ b/tools/cocos-importer/src/manifest-reader.js @@ -197,7 +197,7 @@ export function buildManifestFromLegacyMeta(legacy) { return { id: String(action.id ?? `legacy-action-${index}`), name: String(action.name ?? `action-${index}`), - export_name: String(action.name ?? `action-${index}`), + export_name: legacyExportName(action, atlas, frameList, index), direction: validDirection(action.direction) ? action.direction : 'default', fps, timing_mode: 'constant-fps', @@ -223,6 +223,27 @@ export function buildManifestFromLegacyMeta(legacy) { return validateManifest(manifest) } +function legacyExportName(action, atlas, frames, index) { + const atlasMatch = typeof atlas.file === 'string' + ? /^atlas\/([^/\\\0]+)\.png$/u.exec(atlas.file) + : null + if (usableLegacySegment(atlasMatch?.[1])) return atlasMatch[1] + + for (const frame of frames) { + if (!frame || typeof frame !== 'object' || Array.isArray(frame)) continue + const frameMatch = typeof frame.file === 'string' + ? /^([^/\\\0]+)_\d+\.png$/u.exec(frame.file) + : null + if (usableLegacySegment(frameMatch?.[1])) return frameMatch[1] + } + + return String(action.name ?? `action-${index}`) +} + +function usableLegacySegment(value) { + return typeof value === 'string' && value !== '.' && value !== '..' +} + function firstAnchor(actions) { for (const rawAction of actions) { if (rawAction && typeof rawAction === 'object' && !Array.isArray(rawAction)) { diff --git a/tools/cocos-importer/test/import-core.test.mjs b/tools/cocos-importer/test/import-core.test.mjs index 1f2d3a52..a24ef31e 100644 --- a/tools/cocos-importer/test/import-core.test.mjs +++ b/tools/cocos-importer/test/import-core.test.mjs @@ -5,7 +5,7 @@ import { existsSync, readFileSync } from 'node:fs' import { dirname, join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' -import { prepareImport, validatePreparedImport } from '../src/import-core.js' +import { prepareImport, prepareImportFromEntries, validatePreparedImport } from '../src/import-core.js' import { readStoredZip } from '../src/zip-reader.js' const testDir = dirname(fileURLToPath(import.meta.url)) @@ -63,6 +63,48 @@ test('prepareImport 在内存中把 Windup ZIP 转换为完整 Cocos 文件集 assert.doesNotThrow(() => validatePreparedImport(prepared)) }) +test('prepareImportFromEntries 可导入显示名与旧版实际目录不同的资产包', () => { + const encoder = new TextEncoder() + const bytes = (value) => encoder.encode(value) + const legacy = { + character: { id: '46', name: '网站看板娘', image: 'character/master.png' }, + outfit: { id: 'default', name: '默认造型' }, + canvas: { w: 1, h: 1 }, + actions: [{ + id: 'walk-south', + name: 'Walk / Forward', + direction: 'south', + fps: 12, + loop: true, + anchor: { x: 0.5, y: 0.92 }, + frames: [{ index: 0, file: 'Walk-Forward-south_000.png' }], + atlas: { + file: 'atlas/Walk-Forward-south.png', + cols: 1, + rows: 1, + cell: { w: 1, h: 1 }, + }, + }], + } + const entries = [ + ['meta.json', bytes(JSON.stringify(legacy))], + ['character/master.png', bytes('master')], + ['frames/Walk-Forward-south/Walk-Forward-south_000.png', bytes('frame')], + ['atlas/Walk-Forward-south.png', bytes('atlas')], + ].map(([relativePath, data]) => ({ + relativePath, + data, + size: data.byteLength, + rootDir: 'legacy-fixture', + })) + + const prepared = prepareImportFromEntries(entries) + assert.equal(prepared.manifest.actions[0].export_name, 'Walk-Forward-south') + assert.ok(prepared.files.has( + 'windup-imports/网站看板娘/默认造型/animations/Walk-Forward-south/Walk-Forward-south_000.png', + )) +}) + test('validatePreparedImport 拒绝缺失 SpriteFrame 源文件的结果', () => { const prepared = prepareImport(fixtureZipBytes()) const framePath = [...prepared.files.keys()].find((path) => path.endsWith('/Walk_000.png')) @@ -139,8 +181,7 @@ test('readStoredZip 在解析前限制条目数、单条目和总解包大小', assert.throws(() => readStoredZip(bytes, { maxTotalBytes: 1 }), /总解包大小/) }) -test('prepareImportFromEntries 按每次输出复制量限制重复素材引用', async () => { - const { prepareImportFromEntries } = await import('../src/import-core.js') +test('prepareImportFromEntries 按每次输出复制量限制重复素材引用', () => { const frameCount = 65 const manifest = { schema_version: 'windup-cocos-import-1.1.0', diff --git a/tools/cocos-importer/test/manifest-reader.test.mjs b/tools/cocos-importer/test/manifest-reader.test.mjs index 325d70a0..ceaa66d8 100644 --- a/tools/cocos-importer/test/manifest-reader.test.mjs +++ b/tools/cocos-importer/test/manifest-reader.test.mjs @@ -216,3 +216,47 @@ test('buildManifestFromLegacyMeta 兼容旧版 action-assets 包', () => { assert.equal(manifest.actions[0].timing_mode, 'constant-fps') assert.equal(manifest.actions[0].frames[0].duration_ms, null) }) + +test('buildManifestFromLegacyMeta 从旧版 atlas 路径恢复实际导出目录', () => { + const legacy = { + character: { id: 46, name: '网站看板娘', image: 'character/master.png' }, + outfit: { id: 'outfit-default', name: '默认造型' }, + canvas: { w: 256, h: 256 }, + actions: [ + { + id: 'walk-south', + name: 'Walk / Forward', + direction: 'south', + fps: 12, + anchor: { x: 0.5, y: 0.92 }, + frames: [{ index: 0, file: 'Walk-Forward-south_000.png' }], + atlas: { + file: 'atlas/Walk-Forward-south.png', + cols: 1, + rows: 1, + cell: { w: 256, h: 256 }, + }, + }, + { + id: 'walk-south-duplicate', + name: 'Walk / Forward', + direction: 'south', + fps: 12, + anchor: { x: 0.5, y: 0.92 }, + frames: [{ index: 0, file: 'Walk-Forward-south-a1b2c3_000.png' }], + atlas: { + file: 'atlas/Walk-Forward-south-a1b2c3.png', + cols: 1, + rows: 1, + cell: { w: 256, h: 256 }, + }, + }, + ], + } + + const manifest = buildManifestFromLegacyMeta(legacy) + assert.deepEqual( + manifest.actions.map((action) => action.export_name), + ['Walk-Forward-south', 'Walk-Forward-south-a1b2c3'], + ) +}) From 87c9a3fbfe0afeebed18ed96ea40385efd5e730b Mon Sep 17 00:00:00 2001 From: xyh202131 <246811510+xyh202131@users.noreply.github.com> Date: Fri, 21 Aug 2026 11:18:07 +0800 Subject: [PATCH 15/15] =?UTF-8?q?chore(cocos):=20=E7=A7=BB=E9=99=A4?= =?UTF-8?q?=E5=AE=A1=E6=9F=A5=E6=97=A0=E5=85=B3=E4=BA=A7=E7=89=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 2 + .../2026-08-20-cocos-one-click-export.md | 117 ----- .../2026-08-20-cocos-web-one-click-import.md | 425 ------------------ ...08-20-cocos-web-one-click-import-design.md | 139 ------ .../dist/windup-cocos-importer.zip | Bin 75310 -> 0 bytes 5 files changed, 2 insertions(+), 681 deletions(-) delete mode 100644 docs/superpowers/plans/2026-08-20-cocos-one-click-export.md delete mode 100644 docs/superpowers/plans/2026-08-20-cocos-web-one-click-import.md delete mode 100644 docs/superpowers/specs/2026-08-20-cocos-web-one-click-import-design.md delete mode 100644 tools/cocos-importer/dist/windup-cocos-importer.zip diff --git a/.gitignore b/.gitignore index 61dcaf3a..84e55acf 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,8 @@ data/ # 运行产物 output/ +tools/cocos-importer/dist/ +tools/cocos-importer/test/.tmp-*/ # 构建缓存 .ruff_cache/ diff --git a/docs/superpowers/plans/2026-08-20-cocos-one-click-export.md b/docs/superpowers/plans/2026-08-20-cocos-one-click-export.md deleted file mode 100644 index 746d6bc5..00000000 --- a/docs/superpowers/plans/2026-08-20-cocos-one-click-export.md +++ /dev/null @@ -1,117 +0,0 @@ -# Cocos Creator 一键导出实现计划 - -> **已被取代:** 本计划记录早期“下载 ZIP + CLI 导入”阶段。网页连接 Creator 全局扩展的一键导入实施以 `docs/superpowers/plans/2026-08-20-cocos-web-one-click-import.md` 为准。 - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** 在不破坏通用导出契约的前提下,为 Windup 增加一个可下载、可验证并可导入 Cocos Creator 3.x 的适配包。 - -**Architecture:** 复用 `AssetExportTarget` 扩展点,让前端导出器在通用 ZIP 中追加 `targets/cocos-creator/` 清单;当前 Cocos 侧先提供纯 Node CLI,将清单与通用图片转换为按 Creator 3.x 结构组织的 SpriteFrame、AnimationClip、Prefab 及 `.meta`。Creator 3.x 扩展待真实运行时验证后再接入。通用层仍是唯一事实源,导入器只消费清单,不反向修改通用导出逻辑。 - -**Tech Stack:** React 19 + TypeScript + Vitest + Vite;Node.js ESM `node:test`;Cocos Creator 3.x ES module 扩展 API(延期接入)。 - -**Spec:** GitHub Issue #94(当前已关闭,作为历史契约参考)与附件 `windup-cocos-export-import.zip` 中的 Cocos 清单、导入器和硬验证样本。 - -## Global Constraints - -- Cocos target 不得修改 `meta.json`、`schema.json`、`frames/` 或 `atlas/` 的通用目录和字段。 -- 导出前继续执行现有质量门禁;任一缺帧、PNG、尺寸或路径校验失败都不得下载残缺包。 -- Cocos 清单使用 `anchor_cocos = { x, y: 1 - y }`;manifest、CLI 和未来扩展必须共享同一规则。 -- 本阶段没有可用的真实 Creator 3.x 实例;UI 和文档必须明确“适配包/导入器”状态,不能声称已完成真实拖入即播放验收。 -- 不新增第三方 npm 依赖;CLI 仅使用 Node 内置模块,ZIP 仅支持导出器当前使用的 STORED 格式。 -- 不创建新 Git 分支,不直接修改上游仓库;代码先在当前 Windup PR 工作区完成并本地验证。 - ---- - -### Task 1: Cocos target 清单与前端一键导出入口 - -**Files:** -- Modify: `frontend/src/features/export-package/cocos-target.ts` -- Create: `frontend/src/features/export-package/cocos-target.test.ts` -- Modify: `frontend/src/features/export-package/export-panel.tsx` -- Modify: `frontend/src/features/export-package/export-panel.test.tsx` -- Modify: `frontend/src/features/export-package/index.ts` - -**Interfaces:** -- Consumes: `AssetExportTargetContext`, `ExportPackageModel`, `GenericExportMetadata`, `PlannedSequence`。 -- Produces: `cocosCreatorTarget`, `COCOS_IMPORT_SCHEMA_VERSION`, `toCocosAnchor`;面板提供默认开启的“导出 Cocos Creator 包”按钮,并允许测试通过 `enableCocosExport={false}` 隐藏。 - -- [x] **Step 1: Write the failing tests** - - 验证 target id、清单版本、`experimental`/`engine` 字段、角色和动作的坐标翻转、帧时长兜底、路径与 README。 - - 验证面板同时保留通用导出按钮,并在质量问题或另一导出任务运行时禁用 Cocos 按钮。 -- [x] **Step 2: Run the focused frontend tests and confirm failure** - - Run: `cd frontend; npm test -- src/features/export-package/cocos-target.test.ts src/features/export-package/export-panel.test.tsx` - - Expected: target export and Cocos button assertions fail because the target remains a placeholder and the panel has no Cocos action. -- [x] **Step 3: Implement the target and button** - - Build `cocos-import.json` from the plan, preserve generic paths, include explicit duration or `Math.round(1000 / fps)`, and generate an honest import README. - - Use a separate export state for the Cocos button; never let one button’s phase overwrite the other. -- [x] **Step 4: Run focused tests and typecheck** - - Run: `cd frontend; npm test -- src/features/export-package/cocos-target.test.ts src/features/export-package/export-panel.test.tsx; npm run typecheck` - - Expected: all focused tests pass and TypeScript exits 0. - -### Task 2: Node Cocos importer core and deterministic output - -**Files:** -- Create: `tools/cocos-importer/package.json` -- Create: `tools/cocos-importer/src/zip-reader.js` -- Create: `tools/cocos-importer/src/manifest-reader.js` -- Create: `tools/cocos-importer/src/asset-planner.js` -- Create: `tools/cocos-importer/src/cocos-bridge.js` -- Create: `tools/cocos-importer/bin/windup-cocos-import.mjs` -- Create: `tools/cocos-importer/test/manifest-reader.test.mjs` -- Create: `tools/cocos-importer/test/asset-planner.test.mjs` -- Create: `tools/cocos-importer/test/bridge-uuid.test.mjs` -- Create: `tools/cocos-importer/test/e2e-cli.test.mjs` -- Create: `tools/cocos-importer/test/verify-output.mjs` - -**Interfaces:** -- Consumes: `cocos-import.json` plus generic ZIP entries produced by Task 1. -- Produces: `parseManifest`, `validateManifest`, `planImport`, `uuidForPath`, `buildCocosMetaFiles`, CLI flags ` --out `, `--dry-run` and explicit `--force`. - -- [x] **Step 1: Write failing Node tests** - - Cover invalid JSON/manifest fields, path-safe planning, duration fallback, deterministic 24-character UUIDs, CLI dry-run, and a real ZIP-to-output run. -- [x] **Step 2: Run importer tests and confirm failure** - - Run: `cd tools/cocos-importer; node --test test/manifest-reader.test.mjs test/asset-planner.test.mjs test/bridge-uuid.test.mjs test/e2e-cli.test.mjs` - - Expected: module/CLI files are missing and tests fail. -- [x] **Step 3: Implement ZIP reader, manifest validation, planner, bridge and CLI** - - Reject unsupported compression methods and unsafe paths. - - Copy source PNGs, emit `.meta`, `.anim`, `.prefab`, and keep SpriteFrame child UUID references internally consistent. - - Preflight every source file, reject dangerous output ancestors, require explicit `--force` for replacement, and keep `--dry-run` read-only. -- [x] **Step 4: Run Node tests and hard validation** - - Run the four Node test files, then `node test/verify-output.mjs 64 64` against the supplied fixture. - - Expected: all tests pass; every PNG, `.meta`, `.anim` and `.prefab` reference resolves. - -### Task 3: Creator 3.x extension packaging and documentation (deferred) - -**Files:** -- Deferred: `tools/cocos-importer/main.js` until a real Creator 3.x runtime is available. -- Modify: `tools/cocos-importer/README.md` -- Modify: `frontend/src/features/export-package/README.md` - -**Interfaces:** -- Consumes: Task 2 manifest/planner/bridge behavior and the Creator extension’s `Editor`/`assetdb` APIs. -- Produces: a copyable `extensions/windup-importer/` extension with ZIP picker, dry-run preview, and import action. - -- [x] **Step 1: Verify the extension API boundary** - - Official Creator 3.8 documentation requires an ES module panel package; the attached CommonJS entrypoint is not copied into the repository. -- [x] **Step 2: Keep the extension isolated until runtime validation** - - The current implementation exposes the verified Node CLI instead of an unverified Creator extension. -- [x] **Step 3: Document installation, limitations, and verification** - - Documentation states that Creator extension support is deferred and that CLI UUIDs are deterministic. -- [ ] **Step 4: Run the real Creator 3.x validation** - - Blocked until a usable Creator 3.x project can open the generated `.anim`, `.meta` and `.prefab` files; the Node hard validation is complete. - -### Task 4: End-to-end regression gate - -**Files:** -- Create: `frontend/src/features/export-package/cocos-target.e2e.test.ts` -- Create: `frontend/src/features/export-package/cocos-target.e2e.extract.test.ts` - -**Interfaces:** -- Consumes: Task 1 target and the existing browser ZIP runtime. -- Produces: regression coverage that a realistic export contains the generic contract plus `targets/cocos-creator/cocos-import.json` and README, and that the generated ZIP can be parsed without a browser. - -- [x] **Step 1: Add an in-memory RGBA PNG runtime and STORED ZIP reader** -- [x] **Step 2: Assert the full target export and extracted paths** -- [x] **Step 3: Run the E2E files with the existing Vitest configuration** -- [x] **Step 4: Run the final available verification commands and record the Creator-runtime limitation explicitly** diff --git a/docs/superpowers/plans/2026-08-20-cocos-web-one-click-import.md b/docs/superpowers/plans/2026-08-20-cocos-web-one-click-import.md deleted file mode 100644 index 2f1b3ef9..00000000 --- a/docs/superpowers/plans/2026-08-20-cocos-web-one-click-import.md +++ /dev/null @@ -1,425 +0,0 @@ -# Cocos Creator 网页一键导入 Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** 让用户首次安装并配对 Cocos Creator 3.8.8 全局扩展后,可以从 Windup 网页单击完成 2D 资产导入、AssetDB 刷新、引用校验和 Prefab 定位。 - -**Architecture:** 网页继续生成带 Cocos target 清单的 Windup 通用 ZIP,并通过固定 localhost HTTP 协议把 ZIP 交给 Creator 扩展。CLI 与扩展复用同一套纯 Node 导入核心;扩展只负责配对、HTTP、事务写盘和 Editor API,网页不复制 Cocos 序列化逻辑。 - -**Tech Stack:** React 19、TypeScript 6、Vitest 4、Node.js ESM、`node:test`、Cocos Creator 3.8.8 Extension API、Editor Message/AssetDB、浏览器 Fetch/Web Crypto。 - -**Spec:** `docs/superpowers/specs/2026-08-20-cocos-web-one-click-import-design.md` - -## Global Constraints - -- 只支持 Cocos Creator `>=3.8.8 <3.9.0` 的 2D 项目。 -- HTTP 服务固定监听 `127.0.0.1:17832`;禁止监听任意外部接口。 -- Cocos 原生序列化只保留在 `tools/cocos-importer`,前端不得复制 `.meta/.anim/.prefab` 生成代码。 -- 所有导入只能写入当前工程的 `assets/windup-imports/`,网页不得提供本地输出路径。 -- Sprite 必须使用 CUSTOM size mode,默认帧率必须使用精确 `index / fps`。 -- 新增代码不引入运行时第三方依赖,不降低现有覆盖率门禁,并尽可能覆盖失败、重试、幂等和回滚分支。 -- 保留通用 ZIP 下载;扩展不可用或版本不兼容时必须可以降级下载。 -- 不创建新分支,不直接 push 或 commit 到 `1024XEngineer/Windup`。 - ---- - -### Task 1: 固化 Cocos 1.1 清单与精确动画时间 - -**Files:** -- Modify: `frontend/src/features/export-package/cocos-target.ts` -- Modify: `frontend/src/features/export-package/cocos-target.test.ts` -- Modify: `tools/cocos-importer/src/manifest-reader.js` -- Modify: `tools/cocos-importer/src/asset-planner.js` -- Modify: `tools/cocos-importer/src/cocos-bridge.js` -- Modify: `tools/cocos-importer/test/manifest-reader.test.mjs` -- Modify: `tools/cocos-importer/test/asset-planner.test.mjs` -- Modify: `tools/cocos-importer/test/bridge-uuid.test.mjs` - -**Interfaces:** -- Produces: `COCOS_IMPORT_SCHEMA_VERSION = 'windup-cocos-import-1.1.0'`。 -- Produces: `timing_mode: 'constant-fps' | 'per-frame'`。 -- Produces: every planned animation frame has `{ index, time, duration, spriteFramePath }` in seconds。 -- Compatibility: `parseManifest()` continues accepting `windup-cocos-import-1.0.0`。 - -- [ ] **Step 1: Write failing timing and fixed-size tests** - -```ts -expect(manifest.schema_version).toBe('windup-cocos-import-1.1.0') -expect(manifest.actions[0].timing_mode).toBe('constant-fps') -expect(manifest.actions[0].frames[0].duration_ms).toBeNull() -``` - -```js -assert.deepEqual(animation.frames.map((frame) => frame.time), [0, 1 / 12, 2 / 12]) -assert.equal(prefabSprite._sizeMode, 0) -assert.equal(prefabUiTransform._contentSize.width, 256) -assert.equal(prefabUiTransform._contentSize.height, 256) -``` - -- [ ] **Step 2: Run the focused tests and confirm the old rounded timing fails** - -Run: - -```powershell -Set-Location frontend -npm test -- src/features/export-package/cocos-target.test.ts -Set-Location ../tools/cocos-importer -node --test test/manifest-reader.test.mjs test/asset-planner.test.mjs test/bridge-uuid.test.mjs -``` - -Expected: schema/timing assertions fail because the current manifest is 1.0 and uses rounded milliseconds; size-mode assertion fails with `_sizeMode: 1`. - -- [ ] **Step 3: Implement 1.1 timing without breaking 1.0 input** - -Use `duration_ms: null` for constant-fps frames. In `planImport`, calculate each frame as: - -```js -const duration = action.timing_mode === 'per-frame' - ? frame.duration_ms / 1000 - : 1 / action.fps -const time = action.timing_mode === 'per-frame' - ? elapsed - : index / action.fps -``` - -Set the generated Sprite to `_sizeMode: 0` and `_isTrimmedMode: false` while retaining the manifest canvas dimensions in UITransform. - -- [ ] **Step 4: Run frontend and importer coverage** - -```powershell -Set-Location frontend -npm run test:coverage -- src/features/export-package/cocos-target.test.ts -Set-Location ../tools/cocos-importer -npm test -``` - -Expected: all focused tests pass; new timing branches and both schema versions are exercised. - -### Task 2: 抽取 CLI 与扩展共享的导入核心 - -**Files:** -- Create: `tools/cocos-importer/src/import-core.js` -- Modify: `tools/cocos-importer/bin/windup-cocos-import.mjs` -- Create: `tools/cocos-importer/test/import-core.test.mjs` -- Modify: `tools/cocos-importer/package.json` - -**Interfaces:** -- Produces: `prepareImport(input: Uint8Array): Promise`。 -- Produces: `PreparedImport = { manifest, plan, files: Map, packFolder, summary }`。 -- Produces: `validatePreparedImport(prepared): ImportSummary`。 -- CLI consumes these functions; extension consumes them in Task 5. - -- [ ] **Step 1: Write tests proving the core is filesystem-independent** - -```js -const prepared = await prepareImport(zipBytes) -assert.equal(prepared.summary.animationCount, 2) -assert.equal(prepared.summary.frameCount, 64) -assert.ok(prepared.files.has(`${prepared.packFolder}/prefabs/网站看板娘-默认造型.prefab`)) -assert.doesNotThrow(() => validatePreparedImport(prepared)) -``` - -- [ ] **Step 2: Run the test and confirm `prepareImport` is missing** - -```powershell -Set-Location tools/cocos-importer -node --test test/import-core.test.mjs -``` - -Expected: FAIL because `src/import-core.js` does not exist. - -- [ ] **Step 3: Move parsing, planning and output assembly behind the pure interface** - -`prepareImport` accepts bytes and returns files without reading or writing disk. Keep directory-input compatibility in the CLI adapter; do not put CLI flags, console output, `process.exit` or Editor calls into `import-core.js`. - -- [ ] **Step 4: Refactor the CLI to write `PreparedImport.files`** - -Keep `--out`, `--dry-run`, `--force` and existing dangerous-output checks. The same real ZIP must produce byte-identical `.anim`, `.prefab` and `.meta` files before and after refactoring. - -- [ ] **Step 5: Run the complete importer suite** - -```powershell -Set-Location tools/cocos-importer -npm test -node test/verify-output.mjs test/.tmp-cli-out 256 256 -``` - -Expected: all tests pass and every generated UUID reference resolves. - -### Task 3: 实现本地协议客户端和首次配对 - -**Files:** -- Create: `frontend/src/features/export-package/cocos-bridge-client.ts` -- Create: `frontend/src/features/export-package/cocos-bridge-client.test.ts` -- Modify: `frontend/src/features/export-package/index.ts` - -**Interfaces:** -- Produces: `CocosBridgeClient` with `health()`, `pair(code)`, `submit(blob, requestId)`, and `getJob(jobId)`. -- Produces: `CocosBridgeError` with stable codes `PLUGIN_UNAVAILABLE`, `PAIRING_REQUIRED`, `ORIGIN_DENIED`, `VERSION_UNSUPPORTED`, `IMPORT_FAILED`. -- Storage key: `windup:cocos-bridge:token:v1`. - -- [ ] **Step 1: Write fetch-contract tests** - -```ts -const client = new CocosBridgeClient({ fetch, storage, baseUrl: 'http://127.0.0.1:17832' }) -await client.pair('123456') -expect(storage.getItem('windup:cocos-bridge:token:v1')).toBe('issued-token') -expect(fetch).toHaveBeenCalledWith('http://127.0.0.1:17832/v1/pair', expect.objectContaining({ method: 'POST' })) -``` - -Cover timeout, aborted request, 401 token removal, protocol mismatch, SHA-256 header, 202 parsing and job polling response validation. - -- [ ] **Step 2: Run the test and confirm the client is missing** - -```powershell -Set-Location frontend -npm test -- src/features/export-package/cocos-bridge-client.test.ts -``` - -- [ ] **Step 3: Implement the client with injected fetch and storage** - -Use a 2-second health timeout and a 30-second upload timeout. Compute SHA-256 with `crypto.subtle.digest`; never log or expose the bearer token. - -- [ ] **Step 4: Run coverage and typecheck** - -```powershell -Set-Location frontend -npm run test:coverage -- src/features/export-package/cocos-bridge-client.test.ts -npm run typecheck -``` - -Expected: all error mappings and token lifecycle branches pass. - -### Task 4: 把导出面板改为“一键导入 + 下载降级” - -**Files:** -- Create: `frontend/src/features/export-package/cocos-one-click.ts` -- Create: `frontend/src/features/export-package/cocos-one-click.test.ts` -- Modify: `frontend/src/features/export-package/export-panel.tsx` -- Modify: `frontend/src/features/export-package/export-panel.test.tsx` - -**Interfaces:** -- Produces: `importIntoCocos(model, client, onPhase): Promise`。 -- Phases: `detecting | validating | packing | uploading | converting | writing | refreshing | verifying`。 -- Reuses: `exportGameAssets(model, { targets: [cocosCreatorTarget] })` without clicking a download anchor. - -- [ ] **Step 1: Write orchestration and UI tests** - -Test these user-visible paths: paired success; first-time code form; invalid code; plugin missing with download fallback; Creator project missing; upload failure; job failure with rollback status; double-click suppression; retry reuses the already built Blob during the same page session. - -```tsx -await user.click(screen.getByRole('button', { name: '一键导入 Cocos' })) -expect(await screen.findByText('已导入到当前 Cocos 工程')).toBeVisible() -expect(screen.getByText('2 个动作,64 帧')).toBeVisible() -``` - -- [ ] **Step 2: Run tests and confirm the current download-only UI fails** - -```powershell -Set-Location frontend -npm test -- src/features/export-package/cocos-one-click.test.ts src/features/export-package/export-panel.test.tsx -``` - -- [ ] **Step 3: Implement one-click orchestration and pairing form** - -Replace the experimental claim with the verified state returned by `/v1/health`. Keep “下载 Cocos 包” as a secondary button and do not remove “导出游戏资产包”. - -- [ ] **Step 4: Run the complete frontend quality gate** - -```powershell -Set-Location frontend -npm run format:check -npm run lint -npm run typecheck -npm run test:coverage -npm run build -``` - -Expected: every command exits 0; existing repository coverage threshold is not reduced. - -### Task 5: 构建无第三方运行时依赖的 Creator 全局扩展 - -**Files:** -- Create: `tools/cocos-importer/extension/package.json` -- Create: `tools/cocos-importer/extension/source/main.js` -- Create: `tools/cocos-importer/extension/source/http-server.js` -- Create: `tools/cocos-importer/extension/source/pairing-store.js` -- Create: `tools/cocos-importer/extension/source/protocol.js` -- Create: `tools/cocos-importer/extension/scripts/build.mjs` -- Create: `tools/cocos-importer/extension/scripts/verify-package.mjs` -- Create: `tools/cocos-importer/extension/test/http-server.test.mjs` -- Create: `tools/cocos-importer/extension/test/pairing-store.test.mjs` - -**Interfaces:** -- Main entry: `dist/main.js` with `load()` and `unload()`. -- HTTP bind: `{ host: '127.0.0.1', port: 17832 }`. -- Pairing persistence: exact Origin plus SHA-256 token digest in Creator global Profile. -- Build output: `tools/cocos-importer/dist/windup-cocos-importer.zip`. - -- [ ] **Step 1: Write real HTTP server tests on an ephemeral loopback port** - -```js -const server = await startServer({ host: '127.0.0.1', port: 0, profile, jobs }) -const address = server.address() -assert.equal(address.address, '127.0.0.1') -``` - -Cover OPTIONS, unpaired health, five-attempt lockout, five-minute expiry, wrong Origin, missing token, token digest comparison, invalid protocol, excessive Content-Length and clean shutdown. - -- [ ] **Step 2: Run tests and confirm the extension modules are missing** - -```powershell -Set-Location tools/cocos-importer/extension -node --test test/http-server.test.mjs test/pairing-store.test.mjs -``` - -- [ ] **Step 3: Implement extension lifecycle and menu commands** - -`load()` starts the server and registers “显示连接码/连接状态”; `unload()` aborts jobs, closes sockets and clears expired codes. A port conflict must produce `BRIDGE_PORT_IN_USE` in Creator Console without retrying other interfaces. - -- [ ] **Step 4: Package only required runtime files** - -The build script copies `source/` and the shared importer core into `dist/extension/`, writes the production package metadata, and creates the installable ZIP with Node built-ins. It must reject a package containing test fixtures, temporary output or local absolute paths. - -- [ ] **Step 5: Run extension tests and inspect the ZIP** - -```powershell -Set-Location tools/cocos-importer/extension -npm test -npm run build -node scripts/verify-package.mjs ../../dist/windup-cocos-importer.zip -``` - -Expected: the ZIP has `package.json`, `dist/main.js` and shared core only; no secrets or test output are present. - -### Task 6: 实现事务写入、AssetDB 刷新和回滚 - -**Files:** -- Create: `tools/cocos-importer/extension/source/import-job.js` -- Create: `tools/cocos-importer/extension/source/creator-assets.js` -- Create: `tools/cocos-importer/extension/test/import-job.test.mjs` -- Create: `tools/cocos-importer/extension/test/creator-assets.test.mjs` - -**Interfaces:** -- Produces: `ImportJobRunner.run({ requestId, zipBytes }): Promise`。 -- Produces: `CreatorAssets.refresh(dbUrl)`, `query(dbUrl)`, and `reveal(dbUrl)` adapters around `Editor.Message.request`. -- Writes only below `/assets/windup-imports/` and stages below `/temp/windup-importer//`. - -- [ ] **Step 1: Write transaction and rollback tests with temporary directories** - -Cover new import, replacement import, same request id, conversion failure before write, failure after backup, AssetDB refresh failure, verification failure, backup restoration and cleanup after success. - -```js -await assert.rejects(() => runner.run(badRequest), /IMPORT_SHA256_MISMATCH/) -assert.equal(await readFile(existingPrefab, 'utf8'), originalPrefab) -assert.equal(await pathExists(stagingDir), false) -``` - -- [ ] **Step 2: Run tests and confirm the job runner is missing** - -```powershell -Set-Location tools/cocos-importer/extension -node --test test/import-job.test.mjs test/creator-assets.test.mjs -``` - -- [ ] **Step 3: Implement strict path validation and transactional replacement** - -Resolve every destination and verify it remains below the exact import root before any write, rename or removal. The rollback path may remove only the just-written pack directory recorded by the active transaction. - -- [ ] **Step 4: Implement the Creator adapter against exported 3.8.8 messages** - -Use the locally exported `Editor.d.ts`/Message Manager names for AssetDB refresh and query. Keep those names isolated in `creator-assets.js` so a future 3.8 patch changes one adapter rather than the import core. - -- [ ] **Step 5: Run extension tests with failure injection** - -```powershell -Set-Location tools/cocos-importer/extension -npm test -``` - -Expected: all success and rollback branches pass; no test can write outside its temporary project root. - -### Task 7: Creator 3.8.8 真实 2D 验收 - -**Files:** -- Create: `tools/cocos-importer/test/fixtures/creator-project/package.json` -- Create: `tools/cocos-importer/test/fixtures/creator-project/settings/v2/packages/project.json` -- Create: `tools/cocos-importer/test/fixtures/creator-project/assets/.gitkeep` -- Create: `tools/cocos-importer/test/creator-runtime-check.mjs` -- Modify: `frontend/src/features/export-package/cocos-target.ts` -- Modify: `frontend/src/features/export-package/cocos-target.test.ts` -- Modify: `tools/cocos-importer/README.md` -- Modify: `frontend/src/features/export-package/README.md` - -**Interfaces:** -- Fixture source: 用户提供的真实“网站看板娘-46-默认造型”帧目录。 -- Expected output: 64 RGBA8 frames at 256×256; `待机` and `行走` each have 32 keys. - -- [ ] **Step 1: Build the extension and install it into Creator 3.8.8** - -Install `tools/cocos-importer/dist/windup-cocos-importer.zip` through Extension Manager as a global extension, enable it, and confirm `/v1/health` reports Creator 3.8.8 and the fixture project. - -- [ ] **Step 2: Exercise the exact browser flow** - -Run the frontend over HTTPS/dev localhost, complete pairing, click “一键导入 Cocos”, and record the returned job ID and final `db://assets/windup-imports/...prefab` URL. - -- [ ] **Step 3: Verify imported assets inside Creator** - -`creator-runtime-check.mjs` must assert two clips, 32 keys per clip, resolved SpriteFrame UUIDs, loop playback and stable 256×256 UITransform. Creator Console must contain no error or warning generated by the imported assets. - -- [ ] **Step 4: Verify replacement and rollback in the real editor** - -Import the same package again and assert only one Prefab and two clips remain. Then create a negative fixture by replacing one Prefab `__uuid__` with an unknown UUID and recomputing its package SHA-256, import it, confirm the job fails during verification, and confirm the previous playable Prefab is restored. - -- [ ] **Step 5: Update user-facing documentation with verified facts only** - -Set `COCOS_TARGET_READINESS.ready` to `true` and update its test only after Steps 1-4 pass. Document installation, first pairing, one-click use, permission prompt, download fallback, supported version, update behavior and uninstall procedure; remove the old “Creator 未实测/实验性” wording at the same time. - -### Task 8: 最终回归、审查和交付包 - -**Files:** -- Modify: `.gitignore` -- Modify: `tools/cocos-importer/README.md` -- Modify: `frontend/src/features/export-package/README.md` - -**Interfaces:** -- Produces: installable `windup-cocos-importer.zip` and verified web one-click flow. -- Produces: SHA-256 for the extension ZIP and the exact test command record. - -- [ ] **Step 1: Remove generated test output from the tracked change set** - -Ignore `tools/cocos-importer/test/.tmp-*`, extension `dist/`, Creator `library/`, `temp/`, `local/` and logs. Do not delete or modify unrelated user files. - -- [ ] **Step 2: Run every repository gate** - -```powershell -Set-Location frontend -npm run format:check -npm run lint -npm run typecheck -npm run test:coverage -npm run build -Set-Location ../tools/cocos-importer -npm test -Set-Location extension -npm test -npm run build -``` - -- [ ] **Step 3: Perform security and code review** - -Review loopback binding, CORS, token handling, ZIP limits, path normalization, transaction scope, log redaction and shutdown. Resolve all Critical, Important and repository-blocking findings before delivery. - -- [ ] **Step 4: Verify the final diff is focused** - -```powershell -git diff --check -git status --short -git diff --stat -``` - -Expected: no temporary Creator project output, local paths, tokens or unrelated changes are included. - -- [ ] **Step 5: Record deliverables without publishing them automatically** - -Report the extension ZIP absolute path, SHA-256, supported Creator version, real-asset result, coverage result and remaining limitations. Commit or push only after the user explicitly requests it and only through the permitted fork/PR workflow. diff --git a/docs/superpowers/specs/2026-08-20-cocos-web-one-click-import-design.md b/docs/superpowers/specs/2026-08-20-cocos-web-one-click-import-design.md deleted file mode 100644 index 8267da3d..00000000 --- a/docs/superpowers/specs/2026-08-20-cocos-web-one-click-import-design.md +++ /dev/null @@ -1,139 +0,0 @@ -# Cocos Creator 网页一键导入设计 - -**日期:** 2026-08-20 -**状态:** 已确认 -**目标引擎:** Cocos Creator 3.8.8,2D 项目 - -## 1. 结论 - -Windup 采用“网页 + Cocos Creator 全局扩展 + localhost HTTP 桥接”。用户只在首次使用时安装扩展、输入一次连接码并允许浏览器访问本机服务;完成配对后,网页上的“一键导入 Cocos”按钮负责打包、上传、导入、刷新和定位 Prefab。 - -不采用纯浏览器静默写磁盘方案。浏览器目录授权不能覆盖所有浏览器,也不能可靠地驱动 Creator 的 AssetDB 刷新;它只作为扩展不可用时的降级方式。 - -## 2. 用户流程 - -### 首次使用 - -1. 用户下载 `windup-cocos-importer.zip`。 -2. 在 Cocos Creator 3.8.8 的扩展管理器中安装为全局扩展并启用。 -3. 扩展仅在 `127.0.0.1:17832` 启动 HTTP 服务。 -4. 用户在 Creator 菜单选择“Windup / 显示连接码”,获得 6 位、5 分钟有效的一次性连接码。 -5. 用户在 Windup 网页输入连接码;扩展记录网页的精确 Origin,并返回随机 256 位令牌。 -6. 网页把令牌保存在该 Origin 的本地存储中。扩展把令牌摘要和 Origin 保存在 Creator 全局 Profile 中,不保存明文令牌。 - -### 后续使用 - -1. 用户打开目标 Cocos 工程和 Windup 资产页。 -2. 点击“一键导入 Cocos”。 -3. 网页复用现有质量门禁生成 Windup ZIP,不触发浏览器下载。 -4. 网页计算 SHA-256,把 ZIP 发送到本地扩展。 -5. 扩展生成 Cocos 原生资源,写入 `assets/windup-imports/<角色>/<造型>/`。 -6. 扩展刷新 AssetDB,校验 Prefab、AnimationClip 和 SpriteFrame 引用,并在资源面板中选中 Prefab。 -7. 网页显示工程名、资源路径、动作数、方向数和导入耗时。 - -## 3. 系统边界 - -```text -Windup exportGameAssets() - -> 通用 ZIP + targets/cocos-creator/cocos-import.json - -> cocos-bridge-client.ts - -> POST http://127.0.0.1:17832/v1/imports - -> Creator 扩展主进程 - -> 复用 tools/cocos-importer 的解析、规划、序列化核心 - -> 事务式写入 assets/windup-imports/ - -> Editor.Message asset-db 刷新与查询 - -> 网页轮询结果 -``` - -网页不实现 `.meta`、`.anim` 或 `.prefab` 序列化。CLI 和 Creator 扩展必须调用同一套导入核心,避免两份 Cocos 格式实现发生漂移。 - -## 4. HTTP 协议 - -协议版本固定为 `windup-cocos-bridge/1.0.0`。 - -### 端点 - -- `GET /v1/health`:返回版本、Creator 版本、当前工程名、是否已配对;不返回本地绝对路径。 -- `POST /v1/pair`:请求体 `{ "code": "123456" }`;只在连接码有效期内接受,成功后返回一次令牌。 -- `POST /v1/imports`:请求体是原始 ZIP;成功接收后返回 `202` 和 `jobId`。 -- `GET /v1/imports/:jobId`:返回 `queued | validating | converting | writing | refreshing | verifying | completed | failed`。 - -除 `/v1/health` 和有效配对窗口内的 `/v1/pair` 外,所有请求必须同时通过精确 Origin 和 Bearer token 校验。服务实现 `OPTIONS` 预检,只向已配对 Origin 返回 CORS 许可。 - -### 上传头 - -- `Authorization: Bearer ` -- `Content-Type: application/zip` -- `X-Windup-Protocol: windup-cocos-bridge/1.0.0` -- `X-Windup-Request-Id: ` -- `X-Windup-SHA256: <64 位小写十六进制>` - -同一 `requestId` 的重复请求返回原任务,不重复写盘。单包最大 256 MiB、ZIP 条目最多 4096 个、单条目最大 32 MiB、解包后总量最大 512 MiB。 - -## 5. 导入事务 - -1. 完整接收 ZIP 后先校验 Content-Length、SHA-256、ZIP 路径和 manifest。 -2. 转换结果写入 `/temp/windup-importer//stage/`,不直接写入 `assets/`。 -3. 检查生成结果中的所有 UUID、SpriteFrame、AnimationClip 和 Prefab 引用。 -4. 目标目录由 manifest 的角色和造型生成,网页不能传入磁盘路径。 -5. 若目标已存在,先移动到同一事务目录的 `backup/`,再把 stage 移入目标。 -6. 调用 AssetDB 刷新目标 `db://assets/windup-imports/<角色>/<造型>` 并等待完成。 -7. 查询导入后的 Prefab 和动画;成功后删除 backup,失败则恢复 backup 并再次刷新。 -8. 任务结果仅保留最近 20 条,扩展卸载时关闭 HTTP 服务并清理未完成的 staging。 - -## 6. Cocos 2D 资产格式 - -- Prefab 根节点包含 `cc.UITransform`、`cc.Sprite` 和 `cc.Animation`。 -- `cc.Sprite._sizeMode` 固定为 `0`(CUSTOM),UITransform 始终保持 Windup 画布尺寸,避免透明裁边导致角色播放时跳动。 -- SpriteFrame 使用 PNG 的 `@f9941` 子资源 UUID;纹理使用 `@6c48a` 子资源 UUID。 -- 默认帧率模式的关键帧时间必须使用精确的 `index / fps`,动画时长使用 `frameCount / fps`,不得使用 `Math.round(1000 / fps)` 累积。 -- 只有确实提供逐帧时长时才使用累计 `duration_ms / 1000`。 -- `anchor_cocos = { x: anchor.x, y: 1 - anchor.y }`;Prefab 的 UITransform 使用该锚点。 -- 每个动作方向生成独立 AnimationClip,名称保持 `<动作>-<方向>`,单向动作保持原动作名。 -- v1 使用逐帧 PNG 驱动动画;atlas 继续随包保留,但不作为动画引用源。 - -`cocos-import.json` 升级为 `windup-cocos-import-1.1.0`,新增 `timing_mode: "constant-fps" | "per-frame"`。常量帧率下 `duration_ms` 保持 `null`,由导入器用 fps 精确计算;旧 `1.0.0` 清单继续兼容。 - -## 7. 前端状态和降级 - -网页状态依次为:检测扩展、等待配对、检查资产、打包、上传、Creator 转换、写入工程、刷新资源库、校验、完成。 - -错误必须给出可执行的中文处理方式: - -- 未找到扩展:显示插件下载与安装说明,同时保留“下载 Cocos 包”。 -- Creator 未打开工程:提示先打开目标 2D 工程。 -- 未配对或令牌失效:展示连接码输入框,不丢失当前导出上下文。 -- 端口占用:Creator 菜单显示占用错误和进程处理建议。 -- 版本不兼容:只允许下载 ZIP,不执行本地导入。 -- 导入失败:显示失败阶段、稳定错误码和回滚结果,不暴露本地绝对路径。 - -## 8. 安全要求 - -- 只监听 `127.0.0.1`,不得监听 `0.0.0.0`、局域网 IP 或 IPv6 全地址。 -- 不执行 ZIP 内脚本,不调用 shell,不接受网页传入的命令或绝对路径。 -- 拒绝 `..`、绝对路径、盘符、反斜杠混淆、NUL、符号链接和重复归一化路径。 -- Origin、令牌、协议版本、摘要和请求大小全部在读取/写盘前校验。 -- 日志隐藏令牌、连接码、用户绝对路径和 ZIP 内容,只记录任务 ID、阶段和稳定错误码。 -- 配对码使用密码学安全随机数,最多尝试 5 次;超过后立即失效。 - -## 9. 验收标准 - -使用用户提供的真实“网站看板娘-46-默认造型”2D 帧资产验收。 - -验收必须同时满足: - -1. 网页首次配对成功,刷新网页后无需再次输入连接码。 -2. 单击后导入 64 张 256×256 RGBA8 PNG,产生“待机”和“行走”两个 AnimationClip,各 32 帧。 -3. 两个动画均能播放、循环并切换 SpriteFrame,控制台无错误或警告。 -4. 动画期间 UITransform 始终为 256×256,角色锚点和脚底位置不跳动。 -5. 重复导入同一资产只更新同一目录,不产生孤立 `.meta`、重复 UUID 或第二份 Prefab。 -6. 人为制造摘要错误、路径穿越、刷新失败和引用缺失时均拒绝导入或完整回滚。 -7. Chrome 当前稳定版通过首次本地网络权限后完成导入;插件不可用时 ZIP 下载仍可使用。 -8. 新增模块尽可能覆盖成功、失败、重试、幂等和回滚分支;不得降低仓库现有覆盖率要求。 - -## 10. 非目标 - -- 不支持 Cocos Creator 2.x、3D 模型、骨骼动画或 Spine。 -- 不自动修改场景、不自动把 Prefab 拖入当前场景、不保存用户场景。 -- 不让远程网页读取工程文件或枚举本机目录。 -- 不删除通用导出格式;Cocos target 仍是通用包上的引擎适配层。 diff --git a/tools/cocos-importer/dist/windup-cocos-importer.zip b/tools/cocos-importer/dist/windup-cocos-importer.zip deleted file mode 100644 index bfc29ffb66551bd88564574795363f13818d8a8c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 75310 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