From 4bcb702c083f4f00e42b752bd29517d19d267e44 Mon Sep 17 00:00:00 2001 From: CyberSeeker_Sea <187097481+xiaocheny214@users.noreply.github.com> Date: Wed, 26 Aug 2026 19:36:52 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat(generation):=20=E7=AB=8B=E7=BB=98=20sh?= =?UTF-8?q?eet=20=E6=BA=90=E6=A0=BC=E6=8C=89=20PerfectPixel=20=E5=8F=A3?= =?UTF-8?q?=E5=BE=84=E5=81=9A=E6=9C=9D=E5=90=91=20QC=20(#779)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 抠图后检查身份/空图/east 正面漂,失败把英文 Hint 喂进下一轮,最多 3 次;三次仍失败则留最好的一张。冻额仍按计划源格数。 --- .../src/windup_ai_engine/prompt/__init__.py | 9 +- .../prompt/prompts/view_sheet.md | 102 +++++-- .../src/windup_ai_engine/prompt/view_sheet.py | 28 +- .../src/windup_ai_engine/slicing/identity.py | 187 ++++++++++++ .../server/orchestrator/executor.py | 170 +---------- .../orchestrator/view_sheet_executor.py | 167 +++++++++-- backend/tests/test_identity_histogram.py | 97 +++++++ backend/tests/test_master_cutout.py | 25 ++ backend/tests/test_prompt_assets.py | 2 + backend/tests/test_view_sheet_executor.py | 265 ++++++++++++++++++ backend/tests/test_view_sheet_prompt.py | 35 +++ .../2026-08-26-four-eight-view-sheet-api.md | 18 +- 12 files changed, 898 insertions(+), 207 deletions(-) create mode 100644 backend/packages/ai_engine/src/windup_ai_engine/slicing/identity.py create mode 100644 backend/tests/test_identity_histogram.py diff --git a/backend/packages/ai_engine/src/windup_ai_engine/prompt/__init__.py b/backend/packages/ai_engine/src/windup_ai_engine/prompt/__init__.py index 07ccd79f..5000829b 100644 --- a/backend/packages/ai_engine/src/windup_ai_engine/prompt/__init__.py +++ b/backend/packages/ai_engine/src/windup_ai_engine/prompt/__init__.py @@ -4,7 +4,12 @@ from .custom import MAX_ACTION_CHARS, build_custom_prompt from .jump import JUMP_PHASES, build_jump_prompt from .presets import ACTION_PRESETS, ActionPreset -from .view_sheet import VIEW_SHEET_PROMPT_VERSION, build_view_sheet_prompt +from .view_sheet import ( + VIEW_SHEET_PROMPT_VERSION, + build_view_sheet_prompt, + master_pose_clause, + pixel_art_clause, +) from .walk import build_walk_prompt # 改动本包任何一个 build_*_prompt 的输出(包括 prompts/*.md 模板)都必须连带把这个 @@ -25,4 +30,6 @@ "PROMPT_VERSION", "VIEW_SHEET_PROMPT_VERSION", "build_view_sheet_prompt", + "master_pose_clause", + "pixel_art_clause", ] diff --git a/backend/packages/ai_engine/src/windup_ai_engine/prompt/prompts/view_sheet.md b/backend/packages/ai_engine/src/windup_ai_engine/prompt/prompts/view_sheet.md index ba333e9d..a250c55d 100644 --- a/backend/packages/ai_engine/src/windup_ai_engine/prompt/prompts/view_sheet.md +++ b/backend/packages/ai_engine/src/windup_ai_engine/prompt/prompts/view_sheet.md @@ -4,14 +4,19 @@ 西 / 西北 / 西南生产路径水平翻转后上传,不调模型 —— 这三节仍保留,避免执行器误调时 落到空提示词。 -正文只写正向计数句。构图约束(单主体、全身、灰底)放在 `## framing`,不抄进每个朝向。 +朝向节的三件套(Required view / Body / Visibility)来自 PerfectPixel Studio 的 +`facingDescs`。正文只写正向计数句。构图约束放在 `## framing`,定妆构图放在 +`## master`。 ## identity ```text This is an image-to-image task. The attached image is the confirmed FRONT-VIEW character master. -Preserve that identity exactly: the same face, hairstyle, body proportions, outfit, colors, -accessories, and silhouette. Change only the camera azimuth around the same standing figure. +It supplies identity only: the same face, hairstyle, body proportions, outfit, colors, +accessories, and silhouette. Facing direction lock (overrides any other facing or view +instruction in this prompt): redraw that identical standing figure at the facing lock below. +Hold that viewing angle for the whole figure. Never drift back toward a front view and never +mirror the character. Left and right of the outfit stay on their own sides of the body. Same idle pose, same scale, same costume, same character. ``` @@ -31,6 +36,35 @@ flat even lighting, plain light-gray background. Exactly one character is in the and the whole body stays inside the frame. ``` +## master + +定妆 `/generation/image` 用。站立参考,不是动作条带。灰底仍由执行器那句 +Plain light-gray 负责,不写洋红。 + +```text +Before drawing, identify and keep the subject's hairstyle, hair color, eye color, outfit +layers, accessories, weapon or signature prop, and dominant colors. Relaxed idle standing +pose, feet level, weight balanced, arms relaxed and readable. A single figure, head to feet, +vertically centered, occupying about three quarters of the canvas height with breathing room +on every side. Almost flat 2D game-sprite view. +``` + +## pixel + +像素项目才拼这一节。措辞全是正面约束:静态图通路没有 negative_prompt。 +来源是 PerfectPixel Studio 的 sprite / low-res 合同,适配本管线:身份锁正视母版, +灰底由 framing 负责,逻辑分辨率由项目精灵尺寸负责,不要写 16×16 / 纯白底 / 洋红。 + +```text +TRUE pixel art on a square grid, like a 32-64px game sprite enlarged on the canvas. +Each cell of the grid holds one flat solid color. Use a palette of six to eight solid colors; +when a reference image is attached, match that image's colors. Chunky square pixels aligned +to the grid, a clean dark 1px outline around the silhouette, solid tone clusters, at most one +highlight step and one shadow step. Compact torso, clear head shape, simple arms and legs. +Faces stay a flat color plane with readable eyes and mouth. The subject is centered with +empty margin on all sides. Every important shape stays readable at thumbnail size. +``` + ## elevation.side ```text @@ -54,63 +88,77 @@ orthographic, no vanishing point. ## south ```text -Front view, camera on the south, zero-degree azimuth. The character faces the viewer: -the face, chest, and both shoulders are square to the camera, both eyes visible, -the left and right sides of the outfit matching the master. +Facing direction lock: front view, camera on the south, zero-degree azimuth, camera directly +in front at eye level. Body orientation: the character faces the viewer directly. +Visibility: full face visible, eyes and mouth, both arms and both legs fully visible and +symmetric. ``` ## east ```text -Right-side profile, camera on the east, ninety-degree azimuth. The character faces right: -a true side view, one eye, the right shoulder and right arm toward the camera, -the left side of the body hidden, the same standing pose as the front master. +Facing direction lock: right-side profile view, camera on the east, ninety-degree azimuth, +camera at the character's right side, perpendicular to the body, strictly 2D profile. +Body orientation: the character faces toward the RIGHT edge of the canvas. +Visibility: true side view, right profile of the face only, one eye and one ear; right arm +and right leg prominent, left limbs fully hidden behind the body. ``` ## north ```text -Back view, camera on the north, one-hundred-eighty-degree azimuth. The character faces away: -the back of the head, the back, and both shoulder blades visible, the face hidden, -left and right reversed from the front master, the same standing pose. +Facing direction lock: back view, camera on the north, one-hundred-eighty-degree azimuth, +camera positioned directly behind the character. Body orientation: the character faces away +from the viewer. Visibility: face completely hidden, only the back of the head and hair +visible; back of the outfit, both arms and legs seen from behind. ``` ## west ```text -Left-side profile, camera on the west, two-hundred-seventy-degree azimuth. The character -faces left: a true side view, one eye, the left shoulder toward the camera, -the same standing pose as the front master. +Facing direction lock: left-side profile view, camera on the west, two-hundred-seventy-degree +azimuth, camera at the character's left side, perpendicular to the body, strictly 2D profile. +Body orientation: the character faces toward the LEFT edge of the canvas. +Visibility: true side view, left profile of the face only, one eye and one ear; left arm +and left leg prominent, right limbs fully hidden behind the body. ``` ## south_east ```text -Three-quarter front view to the right, camera on the south-east, forty-five-degree azimuth. -The face and the right side are both visible, the left side receding, the same standing pose -as the front master. +Facing direction lock: three-quarter front-right view, camera on the south-east, +forty-five-degree azimuth, camera at front-right, rotated about forty-five degrees from +straight ahead. Body orientation: the character is turned about forty-five degrees to the +right, mostly facing the viewer. Visibility: three-quarter face with both eyes visible, +right side emphasized; right arm and leg fully visible, left side partially visible. ``` ## north_east ```text -Three-quarter back view to the right, camera on the north-east, one-hundred-thirty-five-degree -azimuth. The back and the right side are both visible, the face mostly hidden, -the same standing pose as the front master. +Facing direction lock: three-quarter back-right view, camera on the north-east, +one-hundred-thirty-five-degree azimuth, camera behind and to the right. +Body orientation: the character is turned away from the viewer, showing the back-right side. +Visibility: face hidden except a hint of the right jaw; back and right shoulder prominent, +right arm and leg visible from behind. ``` ## south_west ```text -Three-quarter front view to the left, camera on the south-west, three-hundred-fifteen-degree -azimuth. The face and the left side are both visible, the right side receding, -the same standing pose as the front master. +Facing direction lock: three-quarter front-left view, camera on the south-west, +three-hundred-fifteen-degree azimuth, camera at front-left. +Body orientation: the character is turned about forty-five degrees to the left, mostly +facing the viewer. Visibility: three-quarter face with both eyes visible, left side +emphasized; left arm and leg fully visible, right side partially visible. ``` ## north_west ```text -Three-quarter back view to the left, camera on the north-west, two-hundred-twenty-five-degree -azimuth. The back and the left side are both visible, the face mostly hidden, -the same standing pose as the front master. +Facing direction lock: three-quarter back-left view, camera on the north-west, +two-hundred-twenty-five-degree azimuth, camera behind and to the left. +Body orientation: the character is turned away from the viewer, showing the back-left side. +Visibility: face hidden except a hint of the left jaw; back and left shoulder prominent, +left arm and leg visible from behind. ``` diff --git a/backend/packages/ai_engine/src/windup_ai_engine/prompt/view_sheet.py b/backend/packages/ai_engine/src/windup_ai_engine/prompt/view_sheet.py index 29eb5c54..f4cac350 100644 --- a/backend/packages/ai_engine/src/windup_ai_engine/prompt/view_sheet.py +++ b/backend/packages/ai_engine/src/windup_ai_engine/prompt/view_sheet.py @@ -11,10 +11,25 @@ from windup_ai_engine.prompt._md import load_section -__all__ = ["VIEW_SHEET_PROMPT_VERSION", "build_view_sheet_prompt"] +__all__ = [ + "VIEW_SHEET_PROMPT_VERSION", + "build_view_sheet_prompt", + "master_pose_clause", + "pixel_art_clause", +] _DOC = "view_sheet.md" -VIEW_SHEET_PROMPT_VERSION = "v1" +VIEW_SHEET_PROMPT_VERSION = "v4" + + +def pixel_art_clause() -> str: + """像素项目的网格 / 限色约束。定妆与 sheet 图生图共用,避免两处各写一版。""" + return load_section(_DOC, "pixel") + + +def master_pose_clause() -> str: + """定妆站立构图。sheet 图生图不拼这一节,身份与机位已在 identity / 朝向节。""" + return load_section(_DOC, "master") def build_view_sheet_prompt( @@ -22,11 +37,15 @@ def build_view_sheet_prompt( *, view: CharacterView | str = CharacterView.TOP_DOWN, extra: str = "", + stylize: str = "none", + feedback: str = "", ) -> str: """拼一条从正视母版转出 ``direction`` 的图生图提示词。 ``direction`` / ``view`` 都过一遍枚举构造:非法值要炸,不能静默落到某一朝向。 ``extra`` 是调用方补的站姿或画风短句,可空;身份与机位以模板为准。 + ``stylize`` 为 ``pixel`` 时追加网格限色约束;项目画风短语仍不进这条提示词。 + ``feedback`` 是上一轮 QC 的英文修正,可空;拼在末尾,不替换身份 / 机位节。 """ azimuth = ActionDirection(direction) @@ -38,7 +57,12 @@ def build_view_sheet_prompt( load_section(_DOC, f"elevation.{perspective.value}"), load_section(_DOC, "framing"), ] + if stylize == "pixel": + parts.append(pixel_art_clause()) extra_text = extra.strip() if extra_text: parts.append(extra_text) + feedback_text = feedback.strip() + if feedback_text: + parts.append(feedback_text) return " ".join(parts) diff --git a/backend/packages/ai_engine/src/windup_ai_engine/slicing/identity.py b/backend/packages/ai_engine/src/windup_ai_engine/slicing/identity.py new file mode 100644 index 00000000..cae5ad31 --- /dev/null +++ b/backend/packages/ai_engine/src/windup_ai_engine/slicing/identity.py @@ -0,0 +1,187 @@ +"""立绘格 vs 正视母版的身份读数与站立 QC。 + +直方图口径跟 PerfectPixel Studio 一样:64-bin RGB 交,门槛 0.40。背面系 +(north / NE / NW)会对背面误报,跳过身份。母版几乎不透明时直方图会被底色污染, +也跳过(他们 ``hasTransparency`` ≥5%)。 + +``inspect_standing_cell`` 是站立版 InspectFrames:空图 / 身份 / east 正面漂为 +Error,贴边只记 Hint。执行器拿 Error 重画,最多 ``STANDING_QC_ATTEMPTS`` 次。 +""" + +from __future__ import annotations + +import dataclasses + +import numpy as np +from PIL import Image + +from windup_common.directions import ActionDirection + +__all__ = [ + "IDENTITY_ERROR_SIM", + "PROFILE_FRONT_DRIFT", + "STANDING_QC_ATTEMPTS", + "StandingInspect", + "has_transparency", + "histogram_intersection", + "identity_similarity", + "inspect_standing_cell", + "is_back_facing", +] + +IDENTITY_ERROR_SIM = 0.40 +STANDING_QC_ATTEMPTS = 3 +PROFILE_FRONT_DRIFT = 0.85 +_INSPECT_EDGE_MARGIN = 2 +_INSPECT_EDGE_MAX = 24 +_BACK_FACING = frozenset( + { + ActionDirection.NORTH, + ActionDirection.NORTH_EAST, + ActionDirection.NORTH_WEST, + } +) +_HIST_BINS = 64 + + +@dataclasses.dataclass(frozen=True) +class StandingInspect: + ok: bool + hints: tuple[str, ...] + identity_sim: float | None + score: int + errors: int + + +def is_back_facing(direction: ActionDirection | str) -> bool: + return ActionDirection(direction) in _BACK_FACING + + +def has_transparency( + image: Image.Image, alpha_thr: int = 128, min_frac: float = 0.05, +) -> bool: + """透明像素占比达到 ``min_frac`` 才算有可抠的底。全涂满的测试图会跳过身份 / 朝向。""" + arr = np.asarray(image.convert("RGBA")) + total = int(arr.shape[0] * arr.shape[1]) + if total == 0: + return False + return float((arr[:, :, 3] <= alpha_thr).sum()) / total >= min_frac + + +def color_histogram(image: Image.Image, alpha_thr: int = 128) -> np.ndarray: + arr = np.asarray(image.convert("RGBA")) + mask = arr[:, :, 3] > alpha_thr + pixels = arr[mask][:, :3] + hist = np.zeros(_HIST_BINS, dtype=np.float64) + if len(pixels) == 0: + return hist + bins = ( + (pixels[:, 0].astype(np.int32) >> 6) << 4 + | (pixels[:, 1].astype(np.int32) >> 6) << 2 + | (pixels[:, 2].astype(np.int32) >> 6) + ) + counts = np.bincount(bins, minlength=_HIST_BINS).astype(np.float64) + total = counts.sum() + if total > 0: + hist = counts / total + return hist + + +def histogram_intersection(a: np.ndarray, b: np.ndarray) -> float: + return float(np.minimum(a, b).sum()) + + +def identity_similarity(frame: Image.Image, master: Image.Image) -> float: + """不透明像素 64-bin RGB 直方图交,0~1。""" + return histogram_intersection(color_histogram(frame), color_histogram(master)) + + +def _subject_wh(image: Image.Image) -> tuple[int, int] | None: + box = image.convert("RGBA").getchannel("A").getbbox() + if box is None: + return None + x0, y0, x1, y1 = box + width, height = x1 - x0, y1 - y0 + if width < 1 or height < 1: + return None + return width, height + + +def inspect_standing_cell( + frame: Image.Image, + master: Image.Image, + direction: ActionDirection | str, +) -> StandingInspect: + """抠图后、吸附 / 对齐前检查一格。只对 Error 判失败;贴边是 Warning。""" + azimuth = ActionDirection(direction) + arr = np.asarray(frame.convert("RGBA")) + height, width = arr.shape[:2] + opaque = arr[:, :, 3] > 128 + content = int(opaque.sum()) + hints: list[str] = [] + errors = 0 + sim: float | None = None + + # PerfectPixel 的 400 是 256² 格上的绝对值;这里用同一条相对口径:画布的 1%。 + min_content = max(width * height // 100, 1) + if content < min_content: + errors += 1 + hints.append( + "Every view must hold one complete, fully drawn full-body character. " + "Leave no view empty or faint." + ) + + if has_transparency(frame): + margin = _INSPECT_EDGE_MARGIN + edge = int( + opaque[:margin, :].sum() + + opaque[-margin:, :].sum() + + opaque[margin:-margin, :margin].sum() + + opaque[margin:-margin, -margin:].sum() + ) + if edge > _INSPECT_EDGE_MAX: + hints.append( + "Keep the whole figure inside the frame with clear padding on all " + "sides; no body part may touch or cross the edge." + ) + + if ( + not is_back_facing(azimuth) + and has_transparency(master) + and has_transparency(frame) + ): + sim = identity_similarity(frame, master) + if sim < IDENTITY_ERROR_SIM: + errors += 1 + hints.append( + "CRITICAL: the previous attempt drew a different-looking character. " + "Copy the attached FRONT-VIEW master's identity exactly — identical " + "hair color, skin tone, outfit colors, proportions and accessories." + ) + + if ( + azimuth is ActionDirection.EAST + and has_transparency(master) + and has_transparency(frame) + ): + south_wh = _subject_wh(master) + cell_wh = _subject_wh(frame) + if south_wh is not None and cell_wh is not None: + south_w, south_h = south_wh + cell_w, cell_h = cell_wh + width_at_south_h = cell_w * (south_h / cell_h) + if width_at_south_h >= PROFILE_FRONT_DRIFT * south_w: + errors += 1 + hints.append( + "Never drift back toward a front view. Required view is a true " + "right-side profile: camera at the character's right, one eye and " + "one ear, left limbs hidden behind the body." + ) + + return StandingInspect( + ok=errors == 0, + hints=tuple(hints), + identity_sim=sim, + score=content - errors * 10, + errors=errors, + ) 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 b3feb3b2..62ddf2c2 100644 --- a/backend/packages/app/src/windup_app/server/orchestrator/executor.py +++ b/backend/packages/app/src/windup_app/server/orchestrator/executor.py @@ -17,7 +17,6 @@ import dataclasses import logging import threading -import time from collections.abc import Callable from dataclasses import dataclass from typing import TYPE_CHECKING @@ -25,6 +24,7 @@ from sqlalchemy.orm import Session from windup_ai_engine.ports import PromptRejected +from windup_ai_engine.prompt import master_pose_clause, pixel_art_clause from windup_ai_engine.slicing.quality import subject_blobs from windup_common.directions import direction_prompt from windup_common.enums import ArtStyle @@ -34,18 +34,10 @@ from windup_framework.gateway.types import Scene from windup_framework.config.quality_gate import settings as gate_settings -from windup_app.server.orchestrator import ( - billing, - client_bake, - generation_io, - i2v_poll, - quality_gate, - task_repo, -) +from windup_app.server.orchestrator import billing, generation_io, i2v_poll, quality_gate, task_repo from windup_app.server.orchestrator._failure import user_message from windup_app.server.orchestrator.i2v_poll import ActionAwaitingVideo -from windup_app.server.orchestrator._fetch import FetchNotAllowed, fetch_own_media, is_own_media -from windup_app.server.orchestrator.client_bake import ActionAwaitingClientBake +from windup_app.server.orchestrator._fetch import fetch_own_media from windup_app.server.orchestrator.model import ( ActionType, CharacterActionInput, @@ -350,8 +342,6 @@ def _complete(s: Session) -> None: generation_io.using_session(session, self._make_session, _complete) except ActionAwaitingVideo: logger.info("动作任务 %s 已提交 i2v,等待延迟轮询", task_id) - except ActionAwaitingClientBake: - logger.info("动作任务 %s 已把出帧挂给浏览器,等它交帧", task_id) except PromptRejected as exc: # 单独捕获而不是落进下面那个兜底:兜底只存 str(exc),``code`` 就丢了,server # 于是分不出"用户改一句话就能过的输入错"和"引擎故障",只能去解析异常文本。 @@ -460,39 +450,6 @@ def _produce_action( # 三渲二那支不取母版,而出口的判官闸口要拿它当参照 —— 不先置 None 的话那支会 # 撞 UnboundLocalError,而它只在有 3D 资产的造型上触发。 master: bytes | None = None - if model_url and client_bake.enabled(): - # 出帧交给浏览器:**模型一个字节都不经过应用机**。原路径要 - # fetch_own_media 把整份 GLB 拉进 worker 内存(还卡在 16MiB 上限上), - # 再起 node + Chromium 软件光栅渲一遍 —— 那两样在这条分支上都不发生。 - if not is_own_media(model_url): - raise FetchNotAllowed( - f"3D 模型地址不在自家对象存储上:{model_url[:80]!r}" - ) - plan = self._get_generator( - _resolve_video_model(input.video_model), cons.directions - ).plan_rendered(action) - deadline = client_bake.open_job( - task_id, - client_bake.ClientBakeSpec( - model_url=model_url, - clip=plan.clip, - direction=plan.direction, - camera_yaw=plan.camera_yaw, - frames=plan.frames, - width=plan.width, - height=plan.height, - material=plan.material, - min_coverage=plan.min_coverage, - ), - ) - logger.info( - "[gen] 造型 %s 走三渲二,出帧挂给浏览器(%s 朝向 × %d 帧,%.0fs 内交)", - input.outfit_id or "?", - plan.direction, - plan.frames, - deadline - time.time(), - ) - raise ActionAwaitingClientBake if model_url: rigged = (self._fetch_model3d or self._download_model3d)(model_url) logger.info( @@ -604,91 +561,6 @@ def _fail(s: Session) -> None: if reset is not None: reset() - def resume_action_client_bake( - self, - task_id: int, - input: CharacterActionInput, - project_id: int | None = None, - *, - reason: str = client_bake.REASON_FRAMES, - detail: str = "", - session: Session | None = None, - ) -> None: - """浏览器那一侧有结果了:交回帧、自报失败,或到期未交。 - - 三种入口收在同一个方法里,是因为它们改的是同一条任务的同一个终态;分开写会 - 让"超时"这一支漏掉解冻(积分冻着、任务永远 RUNNING)。 - """ - reset = None - try: - def _mark_running(s: Session) -> ProjectConstraints: - task = task_repo.get_task(s, task_id) - if task is None: - raise RuntimeError(f"任务 {task_id} 不存在") - if task.status in (TaskStatus.COMPLETED, TaskStatus.FAILED): - raise _PollSkip(f"任务 {task_id} 已终态") - return (self._fetch_constraints or _load_constraints)(s, project_id) - - try: - cons = generation_io.using_session(session, self._make_session, _mark_running) - except _PollSkip: - client_bake.clear(task_id) - return - - loaded = client_bake.load_spec(task_id) - if loaded is None: - # 登记已经不在了:要么另一条消息已经收口,要么状态过期。不重复判失败。 - logger.info("动作任务 %s 没有待出帧登记,跳过", task_id) - return - spec, _deadline = loaded - - if reason != client_bake.REASON_FRAMES: - message = ( - f"浏览器出帧超时({client_bake.DEADLINE_S:.0f}s 未交帧)" - if reason == client_bake.REASON_TIMEOUT - else f"浏览器出帧失败:{detail or '未给出原因'}" - ) - client_bake.clear(task_id) - - def _fail_client(s: Session) -> None: - _close_failed(s, task_id, message) - - generation_io.using_session(session, self._make_session, _fail_client) - return - - frames = client_bake.collect_frames(task_id, spec.frames) - reset = bind_call_context( - task_id=str(task_id), - start_from_model=_resolve_video_model(input.video_model), - ) - card, action, canvas = self._action_spec(input, cons) - progress: ProgressPort = _TaskProgress(task_id=task_id, project_id=project_id) - generated = self._get_generator( - _resolve_video_model(input.video_model), cons.directions - ).finish_rendered(frames, card, action, progress, canvas=canvas) - result = self._deliver_generated(generated, input, cons, None) - client_bake.clear(task_id) - - def _complete(s: Session) -> None: - task_repo.update_result(s, task_id, _ACTION_RESULT, result) - _settle_credit(s, task_id, success=True) - - generation_io.using_session(session, self._make_session, _complete) - except Exception as exc: # noqa: BLE001 —— 兜底后处理/上传/网络异常 - logger.exception("动作任务 %s 的浏览器出帧收口失败", task_id) - if session is not None: - session.rollback() - client_bake.clear(task_id) - error_message = user_message(exc) - - def _fail(s: Session) -> None: - _close_failed(s, task_id, error_message) - - generation_io.using_session(session, self._make_session, _fail) - finally: - if reset is not None: - reset() - def _action_spec( self, input: CharacterActionInput, cons: ProjectConstraints ) -> tuple[CharacterCard, ActionSpec, tuple[int, int]]: @@ -861,36 +733,20 @@ class _LazyRenderStrategy(DerivationStrategy): route = GenRoute.RENDER_3D def __init__(self) -> None: - self._plain: DerivationStrategy | None = None - self._with_stage: DerivationStrategy | None = None - - def _build(self, renderer): - from windup_ai_engine.strategy.concrete import RenderFrameStrategy - - return RenderFrameStrategy(renderer, directions=renderer_directions) - - def _no_stage(self): - """出帧参数与后处理两条路都用不到出帧台,**不要**顺手把它 import 进来: - worker 镜像里没有 node / Chromium 时,那一行 import 本身还能过,真正的 - 代价是把 700MB 量级的运行时依赖重新变成部署前提。""" - if self._plain is None: - self._plain = self._build(None) - return self._plain - - def plan(self, action): - return self._no_stage().plan(action) - - def frames_from_client(self, frames, card, action, progress): - return self._no_stage().frames_from_client(frames, card, action, progress) + self._inner: DerivationStrategy | None = None def derive(self, card, action, source, progress): - if self._with_stage is None: + if self._inner is None: + from windup_ai_engine.strategy.concrete import RenderFrameStrategy from windup_framework.providers.render3d import ( LocalSpriteRenderProvider, ) - self._with_stage = self._build(LocalSpriteRenderProvider()) - return self._with_stage.derive(card, action, source, progress) + self._inner = RenderFrameStrategy( + LocalSpriteRenderProvider(), + directions=renderer_directions, + ) + return self._inner.derive(card, action, source, progress) return _LazyRenderStrategy() @@ -1058,6 +914,9 @@ def _fetch_style(url: str) -> bytes | None: ] if cons.style: parts.append(f"Art style: {cons.style}.") + if cons.stylize == "pixel": + parts.append(pixel_art_clause()) + parts.append(master_pose_clause()) parts.append("Plain light-gray background, no shadow.") # 图生图模式:明确标注参考图用途。只有角色母版、没有项目风格图时同样必须写明 @@ -1373,7 +1232,6 @@ def _make_session(self) -> Session: executor = ActionTaskExecutor() run_action_task = executor.run_action_task resume_action_poll = executor.resume_action_poll -resume_action_client_bake = executor.resume_action_client_bake image_executor = ImageTaskExecutor() run_image_task = image_executor.run_image_task direction_set_executor = DirectionSetTaskExecutor(image_executor=image_executor) diff --git a/backend/packages/app/src/windup_app/server/orchestrator/view_sheet_executor.py b/backend/packages/app/src/windup_app/server/orchestrator/view_sheet_executor.py index 332cf302..e6ec64f5 100644 --- a/backend/packages/app/src/windup_app/server/orchestrator/view_sheet_executor.py +++ b/backend/packages/app/src/windup_app/server/orchestrator/view_sheet_executor.py @@ -1,6 +1,8 @@ """四向 / 八向立绘 sheet 编排。 -从已确认正视母版(south)图生图出源方向,镜像格水平翻转后上传,再拼一张 3×3 罗盘。 +从已确认正视母版(south)图生图出源方向;抠图后按 PerfectPixel 口径检查身份 / +空图 / east 正面漂,失败把英文 Hint 喂进下一轮,最多 3 次,不行留最好的一张。 +镜像格水平翻转后上传,再拼一张 3×3 罗盘。 不经过 ``ImageTaskExecutor._produce_image`` / ``DirectionSetTaskExecutor``。 web **不得 import 本模块**(与 ``executor`` 同门禁:会牵出 ai_engine)。 @@ -18,7 +20,14 @@ from PIL import Image from sqlalchemy.orm import Session +from windup_ai_engine.postprocess import master_pixel_spec, to_pixel_art from windup_ai_engine.prompt import build_view_sheet_prompt +from windup_ai_engine.slicing.identity import ( + STANDING_QC_ATTEMPTS, + identity_similarity, + inspect_standing_cell, + is_back_facing, +) from windup_ai_engine.slicing.quality import subject_blobs from windup_common.directions import ActionDirection from windup_common.models import CharacterView @@ -95,6 +104,86 @@ def flip_horizontal(png: bytes) -> bytes: return buf.getvalue() +def _subject_box(im: Image.Image) -> tuple[int, int, int, int] | None: + """不透明包围盒,右/下为开区间。全透明返回 None。""" + return im.convert("RGBA").getchannel("A").getbbox() + + +def pack_cell_to_master( + png: bytes, + master: Image.Image, + width: int, + height: int, + *, + nearest: bool, +) -> bytes: + """把一格主体缩放到与南向母版同高,脚底与水平中心对齐后再写入 ``width``×``height``。 + + 对应 PerfectPixel ``ExtractFrames`` 的公共缩放 + 基线,但标尺是已确认的 south + 立绘,不是条带里最高的一帧。量不到包围盒时退回 ``_fit_to``。画布装不下时 + 先按画布收一档缩放,再夹进画布 —— 不改母版 URL,也不在拼 sheet 时二次缩放。 + """ + src = Image.open(io.BytesIO(png)).convert("RGBA") + master_rgba = master.convert("RGBA") + master_box = _subject_box(master_rgba) + src_box = _subject_box(src) + if master_box is None or src_box is None: + return _fit_to(png, width, height, smooth=not nearest) + mx0, my0, mx1, my1 = master_box + sx0, sy0, sx1, sy1 = src_box + sub_w, sub_h = sx1 - sx0, sy1 - sy0 + master_h = my1 - my0 + if sub_w < 1 or sub_h < 1 or master_h < 1: + return _fit_to(png, width, height, smooth=not nearest) + scale = min(master_h / sub_h, width / sub_w, height / sub_h) + new_w = max(1, round(sub_w * scale)) + new_h = max(1, round(sub_h * scale)) + crop = src.crop((sx0, sy0, sx1, sy1)) + if (new_w, new_h) != (sub_w, sub_h): + crop = crop.resize( + (new_w, new_h), + Image.NEAREST if nearest else Image.LANCZOS, + ) + left = round((mx0 + mx1) / 2 - new_w / 2) + top = my1 - new_h + if left < 0: + left = 0 + elif left + new_w > width: + left = width - new_w + if top < 0: + top = 0 + elif top + new_h > height: + top = height - new_h + canvas = Image.new("RGBA", (width, height), (0, 0, 0, 0)) + canvas.alpha_composite(crop, (left, top)) + buf = io.BytesIO() + canvas.save(buf, "PNG") + return buf.getvalue() + + +def restore_pixel_cell(png: bytes, master: Image.Image, width: int, height: int) -> bytes: + """按正视母版的逻辑网格与色板吸附一格,再 contain 进精灵画布。 + + Perfect Pixel 的第二步是检测网格再采样;这里复用动作链路已有的 + ``master_pixel_spec`` / ``to_pixel_art``,不另引 OpenCV。量不出网格时退回 + NEAREST ``_fit_to``,与非像素路径的尺寸落实同一出口。 + """ + try: + logical_h, palette = master_pixel_spec(master) + except Exception: + return _fit_to(png, width, height, smooth=False) + if logical_h <= 8: + return _fit_to(png, width, height, smooth=False) + snapped = to_pixel_art( + Image.open(io.BytesIO(png)).convert("RGBA"), + target_h=logical_h, + palette=palette, + ) + buf = io.BytesIO() + snapped.save(buf, "PNG") + return _fit_to(buf.getvalue(), width, height, smooth=False) + + def compose_compass_sheet( cells: dict[ActionDirection, bytes], width: int, @@ -247,26 +336,56 @@ def _produce_sheets( matte = self._get_matte() upload = self._upload or self._upload_image refs = [master_png] + south_im = Image.open(io.BytesIO(master_png)).convert("RGBA") def _gen_one(job: tuple[int, ActionDirection]) -> bytes: sheet_i, direction = job - reset_call = fresh_gateway_request(start_from_model=spread[sheet_i]) - try: - prompt = build_view_sheet_prompt(direction, view=view, extra=extra) - return image_gen.gen_image(prompt, refs) - finally: - reset_call() - - raws = generation_io.io_map(_gen_one, jobs) + feedback = "" + best_cut: bytes | None = None + best_score = -10**9 + for _attempt in range(STANDING_QC_ATTEMPTS): + reset_call = fresh_gateway_request(start_from_model=spread[sheet_i]) + try: + prompt = build_view_sheet_prompt( + direction, + view=view, + extra=extra, + stylize=cons.stylize, + feedback=feedback, + ) + raw = image_gen.gen_image(prompt, refs) + finally: + reset_call() + cut = matte.cutout(raw) + insp = inspect_standing_cell( + Image.open(io.BytesIO(cut)).convert("RGBA"), + south_im, + direction, + ) + if insp.score > best_score: + best_cut, best_score = cut, insp.score + if insp.ok: + return cut + feedback = ( + "QUALITY CORRECTIONS detected by automated inspection " + "(fix all of these): " + " ".join(insp.hints) + ) + assert best_cut is not None + return best_cut + + cuts = generation_io.io_map(_gen_one, jobs) fitted: list[bytes] = [] cut_images: list[Image.Image] = [] - for raw in raws: - png = _fit_to( - matte.cutout(raw), - input.width, - input.height, - smooth=cons.stylize != "pixel", - ) + for cut in cuts: + if cons.stylize == "pixel": + png = restore_pixel_cell(cut, south_im, input.width, input.height) + png = pack_cell_to_master( + png, south_im, input.width, input.height, nearest=True, + ) + else: + png = pack_cell_to_master( + cut, south_im, input.width, input.height, nearest=False, + ) cut_images.append(Image.open(io.BytesIO(png)).convert("RGBA")) fitted.append(png) @@ -316,11 +435,23 @@ def _gen_one(job: tuple[int, ActionDirection]) -> bytes: ) sheets.append(CharacterViewSheetCandidate(sheet_url=sheet_url, cells=cells)) - south_im = Image.open(io.BytesIO(master_png)).convert("RGBA") + identity_sim = [] + for (_, direction), img in zip(jobs, cut_images, strict=True): + if is_back_facing(direction): + continue + identity_sim.append( + { + "direction": direction.value, + "sim": round(identity_similarity(img, south_im), 4), + } + ) return CharacterViewSheetOutput( type=task_type.value, sheets=sheets, - quality={"subject_blobs": list(subject_blobs([south_im, *cut_images]))}, + quality={ + "subject_blobs": list(subject_blobs([south_im, *cut_images])), + "identity_sim": identity_sim, + }, ) def _get_image(self): diff --git a/backend/tests/test_identity_histogram.py b/backend/tests/test_identity_histogram.py new file mode 100644 index 00000000..57b64789 --- /dev/null +++ b/backend/tests/test_identity_histogram.py @@ -0,0 +1,97 @@ +"""立绘身份直方图与站立 QC:背面跳过,换色低于 PerfectPixel 的 0.40 门槛。""" + +from __future__ import annotations + +from PIL import Image + +from windup_ai_engine.slicing.identity import ( + IDENTITY_ERROR_SIM, + PROFILE_FRONT_DRIFT, + identity_similarity, + inspect_standing_cell, + is_back_facing, +) +from windup_common.directions import ActionDirection + + +def _blob( + color: tuple[int, int, int, int], + *, + x: int, + y: int, + w: int, + h: int, + canvas: tuple[int, int] = (64, 96), +) -> Image.Image: + im = Image.new("RGBA", canvas, (0, 0, 0, 0)) + for yy in range(y, y + h): + for xx in range(x, x + w): + im.putpixel((xx, yy), color) + return im + + +def test_back_facing_matches_perfectpixel_north_family(): + assert is_back_facing(ActionDirection.NORTH) + assert is_back_facing(ActionDirection.NORTH_EAST) + assert is_back_facing("north_west") + assert not is_back_facing(ActionDirection.EAST) + assert not is_back_facing(ActionDirection.SOUTH_EAST) + + +def test_identical_sprites_score_near_one(): + im = Image.new("RGBA", (32, 32), (200, 40, 40, 255)) + assert identity_similarity(im, im) == 1.0 + + +def test_recolored_sprite_falls_below_identity_error_sim(): + red = Image.new("RGBA", (32, 32), (200, 40, 40, 255)) + blue = Image.new("RGBA", (32, 32), (40, 40, 200, 255)) + assert identity_similarity(red, blue) < IDENTITY_ERROR_SIM + + +def test_opaque_master_skips_identity_and_facing_errors(): + south = Image.new("RGBA", (64, 96), (200, 40, 40, 255)) + east = Image.new("RGBA", (64, 96), (40, 40, 200, 255)) + insp = inspect_standing_cell(east, south, ActionDirection.EAST) + assert insp.ok + assert insp.identity_sim is None + assert insp.errors == 0 + + +def test_empty_cell_is_an_error(): + south = _blob((200, 40, 40, 255), x=20, y=20, w=24, h=56) + empty = Image.new("RGBA", (64, 96), (0, 0, 0, 0)) + insp = inspect_standing_cell(empty, south, ActionDirection.EAST) + assert not insp.ok + assert any("empty or faint" in hint for hint in insp.hints) + + +def test_east_as_wide_as_south_is_front_drift(): + south = _blob((200, 40, 40, 255), x=20, y=20, w=24, h=56) + front = _blob((200, 40, 40, 255), x=20, y=20, w=24, h=56) + insp = inspect_standing_cell(front, south, ActionDirection.EAST) + assert not insp.ok + assert any("Never drift back toward a front view" in hint for hint in insp.hints) + slim = _blob((200, 40, 40, 255), x=4, y=48, w=8, h=28) + ok = inspect_standing_cell(slim, south, ActionDirection.EAST) + assert ok.ok + width_norm = 8 * (56 / 28) + assert width_norm < PROFILE_FRONT_DRIFT * 24 + + +def test_north_skips_identity_and_does_not_check_front_drift(): + south = _blob((200, 40, 40, 255), x=20, y=20, w=24, h=56) + north = _blob((40, 40, 200, 255), x=20, y=20, w=24, h=56) + insp = inspect_standing_cell(north, south, ActionDirection.NORTH) + assert insp.ok + assert insp.identity_sim is None + + +def test_recolored_transparent_east_fails_identity(): + south = _blob((200, 40, 40, 255), x=20, y=20, w=24, h=56) + east = _blob((40, 40, 200, 255), x=4, y=48, w=8, h=28) + insp = inspect_standing_cell(east, south, ActionDirection.EAST) + assert not insp.ok + assert insp.identity_sim is not None + assert insp.identity_sim < IDENTITY_ERROR_SIM + assert any("different-looking character" in hint for hint in insp.hints) diff --git a/backend/tests/test_master_cutout.py b/backend/tests/test_master_cutout.py index 3fc19adf..00b2602d 100644 --- a/backend/tests/test_master_cutout.py +++ b/backend/tests/test_master_cutout.py @@ -378,3 +378,28 @@ def gen_image(self, prompt, refs): assert len(urls) == 3 assert quality["subject_blobs"] == [1, 1, 1] assert len(threads) > 1 + + +def test_pixel_project_image_prompt_includes_grid_clause(): + """像素定妆走 Perfect Pixel 那套正面网格约束,不只丢一句 Art style: pixel art。""" + prompts: list[str] = [] + + class _Gen: + def gen_image(self, prompt, refs): + del refs + prompts.append(prompt) + return _master() + + ImageTaskExecutor( + image=_Gen(), + matte=_BackgroundMatte(), + upload=lambda b: "u", + )._produce_image( + CharacterImageInput(prompt="勇者", width=64, height=64, num_images=1), + ProjectConstraints(sprite_w=64, sprite_h=64, stylize="pixel", style="pixel art"), + ) + assert prompts + assert "Chunky square pixels" in prompts[0] + assert "Art style: pixel art" in prompts[0] + assert "three quarters of the canvas height" in prompts[0] + assert "hairstyle" in prompts[0] diff --git a/backend/tests/test_prompt_assets.py b/backend/tests/test_prompt_assets.py index e7a0b3da..18b8fd98 100644 --- a/backend/tests/test_prompt_assets.py +++ b/backend/tests/test_prompt_assets.py @@ -40,6 +40,8 @@ "identity", "pose", "framing", + "master", + "pixel", "elevation.side", "elevation.top-down", "elevation.isometric", diff --git a/backend/tests/test_view_sheet_executor.py b/backend/tests/test_view_sheet_executor.py index a0c8da85..d9028d3f 100644 --- a/backend/tests/test_view_sheet_executor.py +++ b/backend/tests/test_view_sheet_executor.py @@ -4,6 +4,7 @@ import io +import numpy as np import pytest from PIL import Image from sqlalchemy import create_engine, select @@ -21,6 +22,8 @@ ViewSheetTaskExecutor, compose_compass_sheet, flip_horizontal, + pack_cell_to_master, + restore_pixel_cell, ) from windup_app.server.quota.model import CreditAccount from windup_common.directions import ActionDirection @@ -110,6 +113,94 @@ def gen_image(self, prompt, refs): return _Gen() +def _blob( + color: tuple[int, int, int, int], + *, + x: int, + y: int, + w: int, + h: int, + canvas: tuple[int, int] = (_W, _H), +) -> bytes: + im = Image.new("RGBA", canvas, (0, 0, 0, 0)) + for yy in range(y, y + h): + for xx in range(x, x + w): + im.putpixel((xx, yy), color) + buf = io.BytesIO() + im.save(buf, "PNG") + return buf.getvalue() + + +def _bbox(png: bytes) -> tuple[int, int, int, int]: + box = _open(png).getchannel("A").getbbox() + assert box is not None + return box + + +def test_pack_cell_to_master_matches_south_height_and_foot_line(): + master_png = _blob((200, 40, 40, 255), x=20, y=20, w=24, h=56) + small_png = _blob((40, 180, 40, 255), x=8, y=40, w=12, h=28) + packed = pack_cell_to_master( + small_png, _open(master_png), _W, _H, nearest=False, + ) + assert _open(packed).size == (_W, _H) + assert _bbox(packed) == _bbox(master_png) + packed_im = _open(packed) + # 放大后的绿块脚底应落在母版脚底,水平中心对齐。 + assert packed_im.getpixel((31, 75))[1] > 100 + assert packed_im.getpixel((0, 0))[3] == 0 + + +def test_four_view_packs_generated_cells_to_south_bbox(sheet_session_factory): + from windup_app.server.orchestrator.executor import ProjectConstraints + + master_png = _blob((200, 40, 40, 255), x=20, y=20, w=24, h=56) + small_east = _blob((200, 40, 40, 255), x=4, y=48, w=8, h=28) + small_north = _blob((40, 40, 200, 255), x=40, y=8, w=16, h=32) + uploaded: dict[str, bytes] = {} + + class _Gen: + def gen_image(self, prompt, refs): + del refs + if "ninety-degree" in prompt: + return small_east + if "one-hundred-eighty" in prompt: + return small_north + raise AssertionError(prompt[:80]) + + def upload(png: bytes) -> str: + url = f"https://cdn.example.com/{id(png)}.png" + uploaded[url] = png + return url + + out = ViewSheetTaskExecutor( + image=_Gen(), + matte=_Matte(), + upload=upload, + fetch_ref=lambda url: master_png, + )._produce_sheets( + _sheet_input(), + GenerationType.CHARACTER_FOUR_VIEW, + ProjectConstraints(perspective=2), + ) + by_dir = {cell.direction: cell for cell in out.sheets[0].cells} + east_png = uploaded[by_dir[ActionDirection.EAST].image_url] + north_png = uploaded[by_dir[ActionDirection.NORTH].image_url] + west_png = uploaded[by_dir[ActionDirection.WEST].image_url] + master_box = _bbox(master_png) + east_box = _bbox(east_png) + assert (east_box[1], east_box[3]) == (master_box[1], master_box[3]) + assert (east_box[0] + east_box[2]) / 2 == (master_box[0] + master_box[2]) / 2 + assert east_box[2] - east_box[0] < master_box[2] - master_box[0] + north_box = _bbox(north_png) + assert (north_box[1], north_box[3]) == (master_box[1], master_box[3]) + assert (north_box[0] + north_box[2]) / 2 == (master_box[0] + master_box[2]) / 2 + west_box = _bbox(west_png) + assert (west_box[1], west_box[3]) == (master_box[1], master_box[3]) + assert west_box[2] - west_box[0] == east_box[2] - east_box[0] + assert by_dir[ActionDirection.SOUTH].image_url == _MASTER_URL + + def test_flip_horizontal_moves_mark_to_the_opposite_edge(): flipped = _open(flip_horizontal(EAST)) assert flipped.size == (_W, _H) @@ -296,3 +387,177 @@ def test_view_sheet_prompt_follows_master_not_project_style(): assert all("Art style" not in p and "中世纪厚涂" not in p for p in prompts) assert all("thirty to forty-five degrees" in p for p in prompts) assert all("像素风勇者" in p for p in prompts) + assert all("Chunky square pixels" in p for p in prompts) + + +def _opaque_colors(png: bytes) -> set[tuple[int, int, int]]: + arr = np.asarray(_open(png)) + rgb = arr[arr[:, :, 3] > 128][:, :3] + if rgb.size == 0: + return set() + return {tuple(c) for c in np.unique(rgb, axis=0)} + + +def _chunky_sprite() -> bytes: + """8px 块、6 色、透明底。逻辑高 80/8=10,过 restore 的 >8 门槛。""" + im = Image.new("RGBA", (_W, _H), (0, 0, 0, 0)) + palette = ( + (20, 20, 20), + (200, 40, 40), + (40, 120, 40), + (40, 40, 180), + (220, 180, 60), + (240, 220, 180), + ) + block = 8 + for gy in range(10): + for gx in range(6): + color = palette[(gx + gy) % 6] + x0, y0 = 8 + gx * block, 8 + gy * block + for y in range(block): + for x in range(block): + im.putpixel((x0 + x, y0 + y), (*color, 255)) + buf = io.BytesIO() + im.save(buf, "PNG") + return buf.getvalue() + + +def _lanczos(png: bytes, scale: int) -> bytes: + im = _open(png) + buf = io.BytesIO() + im.resize((im.width * scale, im.height * scale), Image.LANCZOS).save(buf, "PNG") + return buf.getvalue() + + +def test_restore_pixel_cell_snaps_antialiased_turnaround_to_master_palette(): + master_png = _chunky_sprite() + master = _open(master_png) + messy_png = _lanczos(master_png, 4) + restored = restore_pixel_cell(messy_png, master, _W, _H) + assert _open(restored).size == (_W, _H) + master_colors = _opaque_colors(master_png) + assert len(_opaque_colors(messy_png)) > len(master_colors) + assert _opaque_colors(restored) <= master_colors + + +def test_pixel_sheet_snaps_generated_cells_to_master_palette(): + from windup_app.server.orchestrator.executor import ProjectConstraints + + master_png = _chunky_sprite() + messy_png = _lanczos(master_png, 4) + uploaded: dict[str, bytes] = {} + + class _Gen: + def gen_image(self, prompt, refs): + del prompt, refs + return messy_png + + def upload(png: bytes) -> str: + url = f"https://cdn.example.com/{id(png)}.png" + uploaded[url] = png + return url + + out = ViewSheetTaskExecutor( + image=_Gen(), + matte=_Matte(), + upload=upload, + fetch_ref=lambda url: master_png, + )._produce_sheets( + _sheet_input(), + GenerationType.CHARACTER_FOUR_VIEW, + ProjectConstraints(stylize="pixel", perspective=2), + ) + east = next(c for c in out.sheets[0].cells if c.direction is ActionDirection.EAST) + assert _open(uploaded[east.image_url]).size == (_W, _H) + assert _opaque_colors(uploaded[east.image_url]) <= _opaque_colors(master_png) + + +def test_sheet_identity_sim_skips_back_facing_and_reads_recolored_east(): + from windup_app.server.orchestrator.executor import ProjectConstraints + from windup_ai_engine.slicing.identity import IDENTITY_ERROR_SIM + + out = ViewSheetTaskExecutor( + image=_gen([]), + matte=_Matte(), + upload=lambda png: f"https://cdn.example.com/{id(png)}.png", + fetch_ref=lambda url: SOUTH, + )._produce_sheets( + _sheet_input(), + GenerationType.CHARACTER_FOUR_VIEW, + ProjectConstraints(perspective=2), + ) + by_dir = {row["direction"]: row["sim"] for row in out.quality["identity_sim"]} + assert "east" in by_dir + assert "north" not in by_dir + assert by_dir["east"] < IDENTITY_ERROR_SIM + + +def test_east_front_drift_retries_once_then_keeps_profile(): + from windup_app.server.orchestrator.executor import ProjectConstraints + + master_png = _blob((200, 40, 40, 255), x=20, y=20, w=24, h=56) + front_east = _blob((200, 40, 40, 255), x=20, y=20, w=24, h=56) + profile_east = _blob((200, 40, 40, 255), x=4, y=48, w=8, h=28) + north_png = _blob((40, 40, 200, 255), x=40, y=8, w=16, h=32) + prompts: list[str] = [] + east_n = 0 + + class _Gen: + def gen_image(self, prompt, refs): + del refs + prompts.append(prompt) + if "one-hundred-eighty" in prompt: + return north_png + nonlocal east_n + east_n += 1 + return front_east if east_n == 1 else profile_east + + out = ViewSheetTaskExecutor( + image=_Gen(), + matte=_Matte(), + upload=lambda png: f"https://cdn.example.com/{id(png)}.png", + fetch_ref=lambda url: master_png, + )._produce_sheets( + _sheet_input(), + GenerationType.CHARACTER_FOUR_VIEW, + ProjectConstraints(perspective=2), + ) + east_prompts = [p for p in prompts if "ninety-degree" in p] + assert east_n == 2 + assert "QUALITY CORRECTIONS" not in east_prompts[0] + assert "QUALITY CORRECTIONS" in east_prompts[1] + assert "Never drift back toward a front view" in east_prompts[1] + assert any(c.direction is ActionDirection.EAST for c in out.sheets[0].cells) + + +def test_qc_keeps_best_after_three_failed_east_attempts(): + from windup_app.server.orchestrator.executor import ProjectConstraints + from windup_ai_engine.slicing.identity import STANDING_QC_ATTEMPTS + + master_png = _blob((200, 40, 40, 255), x=20, y=20, w=24, h=56) + front_east = _blob((200, 40, 40, 255), x=20, y=20, w=24, h=56) + north_png = _blob((40, 40, 200, 255), x=40, y=8, w=16, h=32) + prompts: list[str] = [] + + class _Gen: + def gen_image(self, prompt, refs): + del refs + prompts.append(prompt) + if "one-hundred-eighty" in prompt: + return north_png + return front_east + + out = ViewSheetTaskExecutor( + image=_Gen(), + matte=_Matte(), + upload=lambda png: f"https://cdn.example.com/{id(png)}.png", + fetch_ref=lambda url: master_png, + )._produce_sheets( + _sheet_input(), + GenerationType.CHARACTER_FOUR_VIEW, + ProjectConstraints(perspective=2), + ) + east_prompts = [p for p in prompts if "ninety-degree" in p] + assert len(east_prompts) == STANDING_QC_ATTEMPTS + assert any(c.direction is ActionDirection.EAST for c in out.sheets[0].cells) + diff --git a/backend/tests/test_view_sheet_prompt.py b/backend/tests/test_view_sheet_prompt.py index e72bcb5a..8dff1515 100644 --- a/backend/tests/test_view_sheet_prompt.py +++ b/backend/tests/test_view_sheet_prompt.py @@ -27,6 +27,8 @@ def test_build_locks_identity_then_camera(): assert text.index("FRONT-VIEW character master") < text.index("ninety-degree") assert "true side view" in text assert "Exactly one character" in text + assert "overrides any other facing" in text + assert "Never drift back toward a front view" in text def test_south_is_front_view_not_east_profile(): @@ -54,12 +56,45 @@ def test_extra_clause_appends_without_replacing_identity(): assert "FRONT-VIEW character master" in text +def test_pixel_stylize_appends_grid_clause_without_art_style_phrase(): + text = build_view_sheet_prompt(ActionDirection.EAST, stylize="pixel") + assert "Chunky square pixels" in text + assert "six to eight solid colors" in text + assert "32-64px game sprite" in text + assert "Art style" not in text + bare = build_view_sheet_prompt(ActionDirection.EAST) + assert "Chunky square pixels" not in bare + + +def test_source_directions_lock_pp_visibility(): + east = build_view_sheet_prompt(ActionDirection.EAST) + north = build_view_sheet_prompt(ActionDirection.NORTH) + south = build_view_sheet_prompt(ActionDirection.SOUTH) + assert "left limbs fully hidden behind the body" in east + assert "face completely hidden" in north + assert "faces the viewer directly" in south + assert "It supplies identity only" in east + + def test_blank_extra_does_not_pad_the_prompt(): bare = build_view_sheet_prompt(ActionDirection.NORTH) padded = build_view_sheet_prompt(ActionDirection.NORTH, extra=" ") assert bare == padded +def test_feedback_appends_after_extra_without_replacing_identity(): + text = build_view_sheet_prompt( + ActionDirection.EAST, + extra="idle stance", + feedback="Never drift back toward a front view.", + ) + assert text.endswith("Never drift back toward a front view.") + assert text.index("FRONT-VIEW character master") < text.index("idle stance") + assert text.index("idle stance") < text.index("Never drift back toward a front view.") + bare = build_view_sheet_prompt(ActionDirection.EAST, extra="idle stance") + assert "Never drift back toward a front view." not in bare + + def test_illegal_direction_or_view_raises(): with pytest.raises(ValueError): build_view_sheet_prompt("eastt") diff --git a/docs/superpowers/specs/2026-08-26-four-eight-view-sheet-api.md b/docs/superpowers/specs/2026-08-26-four-eight-view-sheet-api.md index 62b227d8..eaa51f93 100644 --- a/docs/superpowers/specs/2026-08-26-four-eight-view-sheet-api.md +++ b/docs/superpowers/specs/2026-08-26-four-eight-view-sheet-api.md @@ -28,6 +28,18 @@ 东向是侧视,必须从图生图出,不能复用正视母版。 +## 与 PerfectPixel Studio 的对齐(立绘,不是动作条带) + +[PerfectPixel Studio](https://github.com/gykim80/perfectpixel-studio) 的 `genDirectionSet` 是某个动作的 21:9 条带(south 条带当运动时序参考)。本接口是**站立立绘 sheet**(south 定妆当身份锚)。动作 i2v、洋红抠图、DP 切帧、`[base, south条带]` 双参考**不对齐**。 + +立绘借这几样: + +- **方向提示词**:`view_sheet.md` 源方向节用他们的 facing 三件套(Required view / Body / Visibility)。身份节带 `Facing direction lock (overrides …)` 和 `Never drift back toward a front view`。正面母版只提供身份,本张改成该机位。 +- **定妆构图**:`## master`(特征核对、放松站立、约占画布高度 3/4)。灰底 + u2net,不写 `#FF00FF`。 +- **像素合同**:`## pixel` 加厚为 32–64px 放大感 / 1px 描边 / 色块;抠图后仍按母版 `master_pixel_spec` 吸附网格。 +- **站立 QC(InspectFrames 口径)**:源格在抠图后、吸附/对齐前检查。空图、非背面且母版有透明时直方图交 `< 0.40`、east 高对齐后宽度仍 ≥ 母版 85%(画成了正面)记 Error。贴边只记 Hint。失败把英文 RetryHints 拼进下一轮提示词,每格最多 3 次;三次仍失败则留分数最好的一张,不把整张 sheet 判失败。north / NE 跳过身份(背面直方图会误报)。读数仍写入 `quality.identity_sim`。 +- **5+3 镜像**:与发布契约一致;前端 `generationDirections` 等于源方向,west / NW / SW 走 `derivedDirections`。 + ## 和现有两个口像什么、不像什么 **信封一样。** 提交立刻返回 `GenerationTaskOut`;进度和终态走: @@ -74,7 +86,7 @@ POST /generation/eight-view 单向项目(`directional_movement == 1`)不要打这两个口:母版本身就是唯一朝向。规格对不上 → `400`。 -`POST /generation/image-set` 不再作为产品路径扩能力;旧任务仍可查询/恢复。本接口 **没有** `retry-failed-directions`:sheet 是一张图,失败整单重提,不按格局部重试。 +`POST /generation/image-set` 不再作为产品路径扩能力;旧任务仍可查询/恢复。本接口 **没有** `retry-failed-directions`:缺一格不能局部补,失败整单重提。源格内部的 QC 重试(最多 3 次、失败留最好的一张)是执行器自己的事,不是这条 HTTP。 ## 请求 @@ -188,7 +200,7 @@ POST /generation/eight-view | (0,2) | south_west | `(0, 2h)` | | (1,1) | 空 | — | -四向只贴 north / west / east / south 四格。八向再贴四个对角。脚底已在各格 `w×h` 内对齐,拼装不再二次对齐。 +四向只贴 north / west / east / south 四格。八向再贴四个对角。贴格前按南向母版不透明包围盒统一身高和脚线(水平对主体中心);镜像跟源格走,不另算一套。拼装只贴已经 `w×h` 的 PNG,不再二次缩放。 `quality` 与 image 同口径:只记账,不参与前端回填,本层不据此判成败。没有读数时为 `null`。 @@ -206,7 +218,7 @@ POST /generation/eight-view - 四向:每张候选 **2** 次(east、north;south 复用正视母版) - 八向:每张候选 **4** 次(east、north、north_east、south_east) -镜像不计费。失败整单解冻;成功按实际上游调用结算,口径与现有 image 预付费相同(提交时冻、终态 capture/release)。 +镜像不计费。格内 QC 重试(每源格最多 3 次)吃在这笔冻结里,不把冻额乘 3。失败整单解冻;成功按实际上游调用结算,口径与现有 image 预付费相同(提交时冻、终态 capture/release)。 ## 确认之后怎么接到动作 From e225e3cce3d9cb765be307b2f36d2ae6e7231993 Mon Sep 17 00:00:00 2001 From: xiaocheny214 <187097481+xiaocheny214@users.noreply.github.com> Date: Wed, 26 Aug 2026 20:14:52 +0800 Subject: [PATCH 2/3] =?UTF-8?q?fix(generation):=20=E8=BF=98=E5=8E=9F=20exe?= =?UTF-8?q?cutor=20=E8=A2=AB=E8=AF=AF=E8=A6=86=E7=9B=96=E7=9A=84=20client-?= =?UTF-8?q?bake=20=E8=B7=AF=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit QC 提交把 executor.py 盖成了 #717 之前的旧副本。四向八向只应改定妆提示词,浏览器出帧导出必须留着,否则 worker 起不来。 --- .../server/orchestrator/executor.py | 166 ++++++++++++++++-- 1 file changed, 156 insertions(+), 10 deletions(-) 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 62ddf2c2..ce2ad7c4 100644 --- a/backend/packages/app/src/windup_app/server/orchestrator/executor.py +++ b/backend/packages/app/src/windup_app/server/orchestrator/executor.py @@ -17,6 +17,7 @@ import dataclasses import logging import threading +import time from collections.abc import Callable from dataclasses import dataclass from typing import TYPE_CHECKING @@ -34,10 +35,18 @@ from windup_framework.gateway.types import Scene from windup_framework.config.quality_gate import settings as gate_settings -from windup_app.server.orchestrator import billing, generation_io, i2v_poll, quality_gate, task_repo +from windup_app.server.orchestrator import ( + billing, + client_bake, + generation_io, + i2v_poll, + quality_gate, + task_repo, +) from windup_app.server.orchestrator._failure import user_message from windup_app.server.orchestrator.i2v_poll import ActionAwaitingVideo -from windup_app.server.orchestrator._fetch import fetch_own_media +from windup_app.server.orchestrator._fetch import FetchNotAllowed, fetch_own_media, is_own_media +from windup_app.server.orchestrator.client_bake import ActionAwaitingClientBake from windup_app.server.orchestrator.model import ( ActionType, CharacterActionInput, @@ -342,6 +351,8 @@ def _complete(s: Session) -> None: generation_io.using_session(session, self._make_session, _complete) except ActionAwaitingVideo: logger.info("动作任务 %s 已提交 i2v,等待延迟轮询", task_id) + except ActionAwaitingClientBake: + logger.info("动作任务 %s 已把出帧挂给浏览器,等它交帧", task_id) except PromptRejected as exc: # 单独捕获而不是落进下面那个兜底:兜底只存 str(exc),``code`` 就丢了,server # 于是分不出"用户改一句话就能过的输入错"和"引擎故障",只能去解析异常文本。 @@ -450,6 +461,39 @@ def _produce_action( # 三渲二那支不取母版,而出口的判官闸口要拿它当参照 —— 不先置 None 的话那支会 # 撞 UnboundLocalError,而它只在有 3D 资产的造型上触发。 master: bytes | None = None + if model_url and client_bake.enabled(): + # 出帧交给浏览器:**模型一个字节都不经过应用机**。原路径要 + # fetch_own_media 把整份 GLB 拉进 worker 内存(还卡在 16MiB 上限上), + # 再起 node + Chromium 软件光栅渲一遍 —— 那两样在这条分支上都不发生。 + if not is_own_media(model_url): + raise FetchNotAllowed( + f"3D 模型地址不在自家对象存储上:{model_url[:80]!r}" + ) + plan = self._get_generator( + _resolve_video_model(input.video_model), cons.directions + ).plan_rendered(action) + deadline = client_bake.open_job( + task_id, + client_bake.ClientBakeSpec( + model_url=model_url, + clip=plan.clip, + direction=plan.direction, + camera_yaw=plan.camera_yaw, + frames=plan.frames, + width=plan.width, + height=plan.height, + material=plan.material, + min_coverage=plan.min_coverage, + ), + ) + logger.info( + "[gen] 造型 %s 走三渲二,出帧挂给浏览器(%s 朝向 × %d 帧,%.0fs 内交)", + input.outfit_id or "?", + plan.direction, + plan.frames, + deadline - time.time(), + ) + raise ActionAwaitingClientBake if model_url: rigged = (self._fetch_model3d or self._download_model3d)(model_url) logger.info( @@ -561,6 +605,91 @@ def _fail(s: Session) -> None: if reset is not None: reset() + def resume_action_client_bake( + self, + task_id: int, + input: CharacterActionInput, + project_id: int | None = None, + *, + reason: str = client_bake.REASON_FRAMES, + detail: str = "", + session: Session | None = None, + ) -> None: + """浏览器那一侧有结果了:交回帧、自报失败,或到期未交。 + + 三种入口收在同一个方法里,是因为它们改的是同一条任务的同一个终态;分开写会 + 让"超时"这一支漏掉解冻(积分冻着、任务永远 RUNNING)。 + """ + reset = None + try: + def _mark_running(s: Session) -> ProjectConstraints: + task = task_repo.get_task(s, task_id) + if task is None: + raise RuntimeError(f"任务 {task_id} 不存在") + if task.status in (TaskStatus.COMPLETED, TaskStatus.FAILED): + raise _PollSkip(f"任务 {task_id} 已终态") + return (self._fetch_constraints or _load_constraints)(s, project_id) + + try: + cons = generation_io.using_session(session, self._make_session, _mark_running) + except _PollSkip: + client_bake.clear(task_id) + return + + loaded = client_bake.load_spec(task_id) + if loaded is None: + # 登记已经不在了:要么另一条消息已经收口,要么状态过期。不重复判失败。 + logger.info("动作任务 %s 没有待出帧登记,跳过", task_id) + return + spec, _deadline = loaded + + if reason != client_bake.REASON_FRAMES: + message = ( + f"浏览器出帧超时({client_bake.DEADLINE_S:.0f}s 未交帧)" + if reason == client_bake.REASON_TIMEOUT + else f"浏览器出帧失败:{detail or '未给出原因'}" + ) + client_bake.clear(task_id) + + def _fail_client(s: Session) -> None: + _close_failed(s, task_id, message) + + generation_io.using_session(session, self._make_session, _fail_client) + return + + frames = client_bake.collect_frames(task_id, spec.frames) + reset = bind_call_context( + task_id=str(task_id), + start_from_model=_resolve_video_model(input.video_model), + ) + card, action, canvas = self._action_spec(input, cons) + progress: ProgressPort = _TaskProgress(task_id=task_id, project_id=project_id) + generated = self._get_generator( + _resolve_video_model(input.video_model), cons.directions + ).finish_rendered(frames, card, action, progress, canvas=canvas) + result = self._deliver_generated(generated, input, cons, None) + client_bake.clear(task_id) + + def _complete(s: Session) -> None: + task_repo.update_result(s, task_id, _ACTION_RESULT, result) + _settle_credit(s, task_id, success=True) + + generation_io.using_session(session, self._make_session, _complete) + except Exception as exc: # noqa: BLE001 —— 兜底后处理/上传/网络异常 + logger.exception("动作任务 %s 的浏览器出帧收口失败", task_id) + if session is not None: + session.rollback() + client_bake.clear(task_id) + error_message = user_message(exc) + + def _fail(s: Session) -> None: + _close_failed(s, task_id, error_message) + + generation_io.using_session(session, self._make_session, _fail) + finally: + if reset is not None: + reset() + def _action_spec( self, input: CharacterActionInput, cons: ProjectConstraints ) -> tuple[CharacterCard, ActionSpec, tuple[int, int]]: @@ -733,20 +862,36 @@ class _LazyRenderStrategy(DerivationStrategy): route = GenRoute.RENDER_3D def __init__(self) -> None: - self._inner: DerivationStrategy | None = None + self._plain: DerivationStrategy | None = None + self._with_stage: DerivationStrategy | None = None + + def _build(self, renderer): + from windup_ai_engine.strategy.concrete import RenderFrameStrategy + + return RenderFrameStrategy(renderer, directions=renderer_directions) + + def _no_stage(self): + """出帧参数与后处理两条路都用不到出帧台,**不要**顺手把它 import 进来: + worker 镜像里没有 node / Chromium 时,那一行 import 本身还能过,真正的 + 代价是把 700MB 量级的运行时依赖重新变成部署前提。""" + if self._plain is None: + self._plain = self._build(None) + return self._plain + + def plan(self, action): + return self._no_stage().plan(action) + + def frames_from_client(self, frames, card, action, progress): + return self._no_stage().frames_from_client(frames, card, action, progress) def derive(self, card, action, source, progress): - if self._inner is None: - from windup_ai_engine.strategy.concrete import RenderFrameStrategy + if self._with_stage is None: from windup_framework.providers.render3d import ( LocalSpriteRenderProvider, ) - self._inner = RenderFrameStrategy( - LocalSpriteRenderProvider(), - directions=renderer_directions, - ) - return self._inner.derive(card, action, source, progress) + self._with_stage = self._build(LocalSpriteRenderProvider()) + return self._with_stage.derive(card, action, source, progress) return _LazyRenderStrategy() @@ -1232,6 +1377,7 @@ def _make_session(self) -> Session: executor = ActionTaskExecutor() run_action_task = executor.run_action_task resume_action_poll = executor.resume_action_poll +resume_action_client_bake = executor.resume_action_client_bake image_executor = ImageTaskExecutor() run_image_task = image_executor.run_image_task direction_set_executor = DirectionSetTaskExecutor(image_executor=image_executor) From dbe51c43fcd98f95fc381c8607c0ee84a219ea56 Mon Sep 17 00:00:00 2001 From: xiaocheny214 <187097481+xiaocheny214@users.noreply.github.com> Date: Wed, 26 Aug 2026 21:13:59 +0800 Subject: [PATCH 3/3] =?UTF-8?q?chore(docs):=20=E4=BB=8E=20QC=20PR=20?= =?UTF-8?q?=E6=8B=BF=E6=8E=89=E5=9B=9B=E5=90=91=E5=85=AB=E5=90=91=20HTTP?= =?UTF-8?q?=20=E5=A5=91=E7=BA=A6=E6=94=B9=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #779 没改接口。PerfectPixel / 格内重试写进 #755 的 API spec 会把执行器实现混进 HTTP 合同。 --- .../2026-08-26-four-eight-view-sheet-api.md | 18 +++--------------- 1 file changed, 3 insertions(+), 15 deletions(-) diff --git a/docs/superpowers/specs/2026-08-26-four-eight-view-sheet-api.md b/docs/superpowers/specs/2026-08-26-four-eight-view-sheet-api.md index eaa51f93..62b227d8 100644 --- a/docs/superpowers/specs/2026-08-26-four-eight-view-sheet-api.md +++ b/docs/superpowers/specs/2026-08-26-four-eight-view-sheet-api.md @@ -28,18 +28,6 @@ 东向是侧视,必须从图生图出,不能复用正视母版。 -## 与 PerfectPixel Studio 的对齐(立绘,不是动作条带) - -[PerfectPixel Studio](https://github.com/gykim80/perfectpixel-studio) 的 `genDirectionSet` 是某个动作的 21:9 条带(south 条带当运动时序参考)。本接口是**站立立绘 sheet**(south 定妆当身份锚)。动作 i2v、洋红抠图、DP 切帧、`[base, south条带]` 双参考**不对齐**。 - -立绘借这几样: - -- **方向提示词**:`view_sheet.md` 源方向节用他们的 facing 三件套(Required view / Body / Visibility)。身份节带 `Facing direction lock (overrides …)` 和 `Never drift back toward a front view`。正面母版只提供身份,本张改成该机位。 -- **定妆构图**:`## master`(特征核对、放松站立、约占画布高度 3/4)。灰底 + u2net,不写 `#FF00FF`。 -- **像素合同**:`## pixel` 加厚为 32–64px 放大感 / 1px 描边 / 色块;抠图后仍按母版 `master_pixel_spec` 吸附网格。 -- **站立 QC(InspectFrames 口径)**:源格在抠图后、吸附/对齐前检查。空图、非背面且母版有透明时直方图交 `< 0.40`、east 高对齐后宽度仍 ≥ 母版 85%(画成了正面)记 Error。贴边只记 Hint。失败把英文 RetryHints 拼进下一轮提示词,每格最多 3 次;三次仍失败则留分数最好的一张,不把整张 sheet 判失败。north / NE 跳过身份(背面直方图会误报)。读数仍写入 `quality.identity_sim`。 -- **5+3 镜像**:与发布契约一致;前端 `generationDirections` 等于源方向,west / NW / SW 走 `derivedDirections`。 - ## 和现有两个口像什么、不像什么 **信封一样。** 提交立刻返回 `GenerationTaskOut`;进度和终态走: @@ -86,7 +74,7 @@ POST /generation/eight-view 单向项目(`directional_movement == 1`)不要打这两个口:母版本身就是唯一朝向。规格对不上 → `400`。 -`POST /generation/image-set` 不再作为产品路径扩能力;旧任务仍可查询/恢复。本接口 **没有** `retry-failed-directions`:缺一格不能局部补,失败整单重提。源格内部的 QC 重试(最多 3 次、失败留最好的一张)是执行器自己的事,不是这条 HTTP。 +`POST /generation/image-set` 不再作为产品路径扩能力;旧任务仍可查询/恢复。本接口 **没有** `retry-failed-directions`:sheet 是一张图,失败整单重提,不按格局部重试。 ## 请求 @@ -200,7 +188,7 @@ POST /generation/eight-view | (0,2) | south_west | `(0, 2h)` | | (1,1) | 空 | — | -四向只贴 north / west / east / south 四格。八向再贴四个对角。贴格前按南向母版不透明包围盒统一身高和脚线(水平对主体中心);镜像跟源格走,不另算一套。拼装只贴已经 `w×h` 的 PNG,不再二次缩放。 +四向只贴 north / west / east / south 四格。八向再贴四个对角。脚底已在各格 `w×h` 内对齐,拼装不再二次对齐。 `quality` 与 image 同口径:只记账,不参与前端回填,本层不据此判成败。没有读数时为 `null`。 @@ -218,7 +206,7 @@ POST /generation/eight-view - 四向:每张候选 **2** 次(east、north;south 复用正视母版) - 八向:每张候选 **4** 次(east、north、north_east、south_east) -镜像不计费。格内 QC 重试(每源格最多 3 次)吃在这笔冻结里,不把冻额乘 3。失败整单解冻;成功按实际上游调用结算,口径与现有 image 预付费相同(提交时冻、终态 capture/release)。 +镜像不计费。失败整单解冻;成功按实际上游调用结算,口径与现有 image 预付费相同(提交时冻、终态 capture/release)。 ## 确认之后怎么接到动作