feat: integrate invitation access, world NPCs, and deployment

This commit is contained in:
2026-09-08 22:22:38 +08:00
parent 2a3125075f
commit 513a3eba31
75 changed files with 9566 additions and 1070 deletions

View File

@@ -17,6 +17,7 @@ import signal
import subprocess
import sys
import time
from scipy import ndimage
from pathlib import Path
from typing import Iterable
@@ -33,15 +34,22 @@ DEFAULT_CUTOUT_SCRIPT = SCRIPT_DIR / "tools" / "birefnet_cutout.py"
DEFAULT_EXPAND_POSE_TRIPLET_SCRIPT = SCRIPT_DIR / "tools" / "expand_pose_triplet.py"
DEFAULT_REFERENCE_STRIPS_DIR = SCRIPT_DIR / "references"
DEFAULT_IDENTITY_REFERENCE_IMAGE = (
DEFAULT_REFERENCE_STRIPS_DIR / "whaleboy_reference_down.png"
DEFAULT_REFERENCE_STRIPS_DIR / "whaleboy_identity_single.png"
)
FRAME_SIZE = 160
SPRITESHEET_COLUMNS = 8
SPRITESHEET_ROWS = 4
# Official style strips keep their source canvas for stable cell extraction.
# The generated identity master is intentionally square and much smaller.
REFERENCE_CANVAS_SIZE = (1536, 1024)
IDENTITY_GENERATION_SIZE = "1024x1024"
SINGLE_POSE_GENERATION_SIZE = "832x832"
SINGLE_POSE_REFERENCE_SIZE = 1024
SINGLE_POSE_REFERENCE_BODY_HEIGHT = 700
POSE_LAYOUT_MARGIN_RATIO = 0.05
POSE_LAYOUT_MAX_BODY_HEIGHT_RATIO = 0.88
POSE_LAYOUT_MAX_BODY_WIDTH_RATIO = 0.68
POSE_LAYOUT_BOTTOM_RATIO = 0.92
LOWER_BODY_EDIT_START_RATIO = 0.68
LOWER_BODY_EDIT_FEATHER_RATIO = 0.025
LOWER_BODY_EDIT_TOP_HALF_WIDTH_RATIO = 0.18
@@ -342,6 +350,15 @@ def _run_with_retries(
raise last_error or RuntimeError("Novamailio command failed")
SINGLE_POSE_LAYOUT_RULES = """
Current generation-canvas placement (mandatory, before later 160x160 assembly):
- Treat the full current image canvas as the frame. Keep exactly one complete character inside it; never crop any hair, hand, shoe, or shadow edge.
- Leave a clean empty margin on every side: at least 5% of the current canvas width on the left and right, and 5% of the current canvas height at the top and bottom.
- Place the visible character bounding-box center on the canvas centerline. The bounding-box center may deviate no more than 8% of the current canvas width.
- Keep the character at the same scale and occupancy as the supplied pose reference. Do not enlarge it until it touches an edge, and do not shrink it into a tiny figure.
""".strip()
def _direction_prompt(direction: str) -> str:
if direction in POSE_TRIPLET_DIRECTIONS:
return _pose_triplet_prompt(direction)
@@ -459,28 +476,44 @@ def _single_pose_prompt(direction: str, pose_index: int) -> str:
reference_row = DIRECTION_REFERENCE_ROWS[direction]
reference_column = (1, 2, 4)[pose_index]
action = POSE_SINGLE_ACTIONS[direction][pose_index]
if pose_index == 0:
motion_rules = """
- This is the neutral anchor pose. Both legs and shoes remain still, compact, symmetrical where the viewing direction permits, and on the same baseline.
- Do not invent a lifted, forward, backward, or active leg. Do not create a walking step in this neutral pose.
- Keep the head, shoulders, torso, hips, arms, and hands in a neutral upright standing pose. Both arms hang straight down at the sides.
""".strip()
identity_input = "Image 1 is the approved identity master. Preserve its identity and rendering exactly while converting it to the requested viewing direction."
reference_match_rule = "Match Image 2's compact neutral stance, leg spacing, shoe placement, and viewing direction exactly."
else:
motion_rules = """
- Keep the head, hair, shoulders, torso, hips, and both arms in the accepted neutral pose. Do not swing or bend an arm, rotate the torso, lean the body, or create a running pose.
- Only the active leg and shoe may differ from the neutral pose. Both feet must remain visible: one active foot and one grounded foot.
- The active leg must be recognizable from the outer silhouette, not only from color or shading.
""".strip()
identity_input = "Image 1 is the already accepted neutral pose and the immutable appearance/upper-body master."
reference_match_rule = "Match Image 2's compact stride and actual leg/shoe geometry. Do not widen the stance or move a foot toward a canvas edge."
return f"""
Task:
Create one single canonical {direction.upper()} pose for a WhaleTown walking animation.
Inputs:
- Image 1 is appearance identity only. Preserve its hair, face or back-head design, outfit, colors, proportions, outline, and rendering style. Ignore all poses in Image 1.
- {identity_input}
- Image 2 is an enlarged single-pose crop extracted from column {reference_column} of the official WhaleTown {reference_row} motion row. Copy the complete body, leg, ankle, shoe, spacing, and perspective geometry visible in Image 2.
- Image 3 is a layout-only construction guide. Use its safe box, centerline, and baseline to place the character. Never copy its lines, colors, background, or guide marks into the output.
Output:
- Exactly ONE full-body character on the entire canvas. Do not create a row, sequence, comparison, duplicate, or additional character.
- Center the character on a flat pure magenta #FF00FF background.
{SINGLE_POSE_LAYOUT_RULES}
- No text, labels, dividers, UI, watermark, props, or shadows.
- Target assembled character height is {spec["body_height"]} px, width about {spec["body_width"]} px, and foot baseline y={spec["foot_y"]} px in a 160x160 frame.
- These pixel targets describe the later 160x160 assembled frame, not the current generation canvas. On the current canvas, match Image 2's character occupancy and scale; do not draw a tiny 116px character.
Required pose:
- {action}
- {DIRECTION_LOCKS[direction]}
- Keep the head, hair, shoulders, torso, hips, and both arms in a perfectly neutral upright standing pose. Both arms hang straight down at the sides. Do not swing or bend an arm, rotate the torso, lean the body, or create a running pose.
- Only the active leg and shoe may differ from a neutral standing pose. The motion must remain compact enough for a calm game walk cycle.
- Both feet must remain visible: one active foot and one grounded/neutral foot where applicable.
- Match Image 2's compact stride and actual leg/shoe geometry. Do not widen the stance or move a foot toward a canvas edge.
- The active leg must be recognizable from the outer silhouette, not only from color or shading.
{motion_rules}
- {reference_match_rule}
Identity locks:
- Image 1 controls identity and all neutral upper-body geometry; Image 2 controls the requested leg and shoe pose only.
@@ -510,6 +543,7 @@ Create one single canonical {direction.upper()} action pose for a WhaleTown walk
Inputs:
- Image 1 is the neutral pose A and the immutable appearance master.
- Image 2 is the already accepted opposite-leg action pose of the SAME character. It shows this source action: {source_action}
- Image 3 is a layout-only construction guide. Use its safe box, centerline, and baseline to place the character. Never copy its lines, colors, background, or guide marks into the output.
- Produce the opposite action shown below. Use Image 2 only to match stride size, forward-depth perspective, and motion strength; switch the active leg exactly as requested.
Required target action:
@@ -527,8 +561,10 @@ Immutable appearance contract:
Output:
- Exactly ONE full-body character, centered on flat pure magenta #FF00FF.
{SINGLE_POSE_LAYOUT_RULES}
- No row, sequence, duplicate, text, labels, dividers, UI, watermark, props, or shadows.
- Target assembled character height is {spec["body_height"]} px, width about {spec["body_width"]} px, and foot baseline y={spec["foot_y"]} px in a 160x160 frame.
- These pixel targets describe the later 160x160 assembled frame, not the current generation canvas. Match Image 1's full-body scale and occupancy exactly; do not draw a tiny 116px character.
""".strip()
@@ -539,16 +575,18 @@ Create one locked front-facing identity master for a WhaleTown V2 player skin.
Input images:
- Image 1 is the player's uploaded character reference. Preserve its main identity: face impression, hairstyle or head silhouette, outfit idea, dominant colors, and overall character feeling.
- Image 2 is the official WhaleTown DOWN/front 8-frame reference row. Use it for sprite structure: body height, body width, baseline, frame spacing, simple rounded proportions, and clean 2D game rendering.
- Image 2 is one official front-facing Sea Breeze Boy style reference. Use it only for WhaleTown sprite structure: body proportions, scale, baseline, dark outline, simple cel shading, and clean 2D game rendering.
- Do not copy Image 2's face, hair, whale hood, outfit, colors, accessories, or character identity.
Output:
- Exactly 8 equal columns x 1 row.
- Same character identity in every column, front-facing, full-body, centered, same size and baseline.
- Exactly ONE front-facing full-body character, centered on the canvas.
- This is a single identity master, not a row, spritesheet, sequence, turnaround, or animation.
- Human character only. If the uploaded image is animal-like, mascot-like, realistic, or non-human, adapt it into a human WhaleTown player character while preserving the visual inspiration.
- Do not invent new accessories, props, hats, tails, ears, weapons, or costume details that are not visible in the uploaded image.
- If the uploaded image and resulting identity have no hat or hood, keep the head uncovered in every frame. Do not add a hat, hood, cap, helmet, animal ears, hair accessory, or new head accessory.
- If the uploaded image has no hat or hood, keep the head uncovered. Do not add a hat, hood, cap, helmet, animal ears, hair accessory, or new head accessory.
- Match WhaleTown style: compact rounded 2D game sprite, clean dark outline, simple cel shading, low texture density.
- Target assembled frame size is 160x160 px; visible character box about 73 px wide, 116 px high, bottom baseline y=137.
- These pixel targets describe later assembly, not the current identity-generation canvas. Match Image 2's single-character occupancy and scale; do not draw a tiny 73x116 character.
- Flat pure magenta #FF00FF background.
- No text, labels, dividers, UI, watermark, props, shadows, green, or magenta/pink on the character.
""".strip()
@@ -561,7 +599,11 @@ def _parse_args(argv: Iterable[str]) -> argparse.Namespace:
parser.add_argument("--name", default="")
parser.add_argument("--result-json", type=Path, required=True)
parser.add_argument("--status-json", type=Path, required=True)
parser.add_argument("--size", default="1536x1024")
parser.add_argument(
"--size",
default=IDENTITY_GENERATION_SIZE,
help="Identity-master generation canvas; the official style reference keeps its own 1536x1024 source canvas.",
)
parser.add_argument(
"--quality", default=os.getenv("SKIN_GENERATION_QUALITY", "medium")
)
@@ -571,6 +613,14 @@ def _parse_args(argv: Iterable[str]) -> argparse.Namespace:
)
parser.add_argument("--assemble-script", type=Path, default=DEFAULT_ASSEMBLE_SCRIPT)
parser.add_argument("--reference-strips-dir", type=Path, default=None)
parser.add_argument("--phase", choices=("all", "identity", "actions", "pose"), default="all")
parser.add_argument("--direction", choices=DIRECTIONS)
parser.add_argument("--pose", choices=POSE_NAMES)
parser.add_argument(
"--qa-feedback",
default="",
help="Previous pose QA failure to incorporate into a targeted repair attempt.",
)
return parser.parse_args(list(argv))
@@ -998,7 +1048,7 @@ def _resolve_identity_reference_path(reference_strips_dir: Path | None) -> Path:
source_dir = reference_strips_dir
if not source_dir.is_absolute():
source_dir = SCRIPT_DIR / source_dir
path = source_dir / "whaleboy_reference_down.png"
path = source_dir / "whaleboy_identity_single.png"
if not path.exists():
raise FileNotFoundError(f"Identity reference image is missing: {path}")
with Image.open(path) as image:
@@ -1047,14 +1097,93 @@ def _create_single_pose_reference(
(SINGLE_POSE_REFERENCE_SIZE, SINGLE_POSE_REFERENCE_SIZE),
(255, 0, 255),
)
canvas.paste(
resized,
((canvas.width - resized.width) // 2, (canvas.height - resized.height) // 2),
)
# Keep the official pose inside a stable, invisible layout box. The model
# sees the same margins for every direction, while the box itself is not
# drawn and therefore cannot leak into the generated sprite.
box_left = round(canvas.width * POSE_LAYOUT_MARGIN_RATIO)
box_right = canvas.width - box_left
box_bottom = round(canvas.height * (1 - POSE_LAYOUT_MARGIN_RATIO))
x = (box_left + box_right - resized.width) // 2
y = box_bottom - resized.height
canvas.paste(resized, (x, y))
output_path.parent.mkdir(parents=True, exist_ok=True)
canvas.save(output_path)
def _create_single_pose_layout_guide(output_path: Path) -> None:
"""Create a layout-only construction guide for the image model."""
size = SINGLE_POSE_REFERENCE_SIZE
margin = round(size * POSE_LAYOUT_MARGIN_RATIO)
image = Image.new("RGB", (size, size), "#f7f7f7")
draw = ImageDraw.Draw(image)
draw.rectangle((margin, margin, size - margin - 1, size - margin - 1), outline="#2f80ed", width=4)
center_x = size // 2
draw.line((center_x, margin, center_x, size - margin), fill="#9aa5ad", width=3)
baseline = round(size * POSE_LAYOUT_BOTTOM_RATIO)
draw.line((margin, baseline, size - margin, baseline), fill="#9aa5ad", width=3)
output_path.parent.mkdir(parents=True, exist_ok=True)
image.save(output_path)
def _normalize_single_pose_cutout(path: Path) -> dict[str, float | int]:
"""Fit a generated cutout into the shared safe layout before QA/assembly."""
with Image.open(path).convert("RGBA") as source:
image = source.copy()
array = np.asarray(image).copy()
alpha = array[:, :, 3] > 16
ys, xs = np.where(alpha)
if not xs.size:
raise RuntimeError("逐格QA失败抠图中没有角色")
width, height = image.size
# Keep the character's meaningful connected regions and discard isolated
# specks before measuring its box. Small separated regions such as shoes
# remain because the area threshold is relative to the main component.
labels, count = ndimage.label(alpha, structure=ndimage.generate_binary_structure(2, 1))
if count:
sizes = np.bincount(labels.ravel())
largest = int(sizes[1:].max()) if sizes.size > 1 else 0
min_component_area = max(12, round(largest * 0.002))
keep = (labels > 0) & (sizes[labels] >= min_component_area)
array[:, :, 3] = np.where(keep, array[:, :, 3], 0).astype(np.uint8)
alpha = keep
edge_counts = (int(alpha[0].sum()), int(alpha[-1].sum()), int(alpha[:, 0].sum()), int(alpha[:, -1].sum()))
edge_noise_limit = max(4, round(min(width, height) * 0.005))
if max(edge_counts) > edge_noise_limit:
raise RuntimeError("逐格QA失败角色主体贴近画布边缘无法安全裁切")
# Remove isolated one-pixel edge residue before calculating the crop box.
if edge_counts[0] <= edge_noise_limit:
array[0, :, 3] = 0
if edge_counts[1] <= edge_noise_limit:
array[-1, :, 3] = 0
if edge_counts[2] <= edge_noise_limit:
array[:, 0, 3] = 0
if edge_counts[3] <= edge_noise_limit:
array[:, -1, 3] = 0
image = Image.fromarray(array, "RGBA")
alpha = np.asarray(image)[:, :, 3] > 16
ys, xs = np.where(alpha)
if not xs.size:
raise RuntimeError("逐格QA失败去除边缘噪点后没有角色")
bbox = (int(xs.min()), int(ys.min()), int(xs.max()) + 1, int(ys.max()) + 1)
body_w, body_h = bbox[2] - bbox[0], bbox[3] - bbox[1]
max_w = max(1, round(width * POSE_LAYOUT_MAX_BODY_WIDTH_RATIO))
max_h = max(1, round(height * POSE_LAYOUT_MAX_BODY_HEIGHT_RATIO))
scale = min(1.0, max_w / body_w, max_h / body_h)
cropped = image.crop(bbox)
if scale < 1.0:
cropped = cropped.resize(
(max(1, round(cropped.width * scale)), max(1, round(cropped.height * scale))),
Image.Resampling.LANCZOS,
)
canvas = Image.new("RGBA", (width, height), (0, 0, 0, 0))
x = round((width - cropped.width) / 2)
y = round(height * POSE_LAYOUT_BOTTOM_RATIO) - cropped.height
y = max(round(height * POSE_LAYOUT_MARGIN_RATIO), min(height - round(height * POSE_LAYOUT_MARGIN_RATIO) - cropped.height, y))
canvas.alpha_composite(cropped, (x, y))
canvas.save(path)
return {"scale": round(scale, 4), "body_width": cropped.width, "body_height": cropped.height, "center_error": 0.0, "components": int(count)}
def _create_lower_body_edit_mask(neutral_pose_path: Path, output_path: Path) -> None:
"""Protect a neutral pose except for a compact, character-relative leg region."""
with Image.open(neutral_pose_path) as source:
@@ -1097,6 +1226,46 @@ def _create_lower_body_edit_mask(neutral_pose_path: Path, output_path: Path) ->
mask.save(output_path)
def _validate_single_pose_cutout(path: Path, pose_name: str, neutral_path: Path | None) -> dict[str, object]:
image = Image.open(path).convert("RGBA")
alpha = np.asarray(image)[:, :, 3] > 16
ys, xs = np.where(alpha)
if not xs.size:
raise RuntimeError("逐格QA失败抠图中没有角色")
width, height = image.size
bbox = (int(xs.min()), int(ys.min()), int(xs.max()) + 1, int(ys.max()) + 1)
body_w, body_h = bbox[2] - bbox[0], bbox[3] - bbox[1]
center_error = abs((bbox[0] + bbox[2]) / 2 - width / 2) / width
failures = []
warnings = []
if not 0.35 <= body_h / height <= 0.95: failures.append(f"角色高度占比异常 {body_h / height:.2f}")
if not 0.08 <= body_w / width <= 0.70: failures.append(f"角色宽度占比异常 {body_w / width:.2f}")
if center_error > 0.18:
if center_error <= 0.24:
warnings.append(f"角色轻微水平偏心 {center_error:.2f}")
else:
failures.append(f"角色水平偏心 {center_error:.2f}")
edge_margin = max(2, round(min(width, height) * 0.01))
edge_pixels = int(alpha[:edge_margin].sum() + alpha[-edge_margin:].sum() + alpha[:, :edge_margin].sum() + alpha[:, -edge_margin:].sum())
if edge_pixels > 0:
warnings.append(f"边缘有少量残留像素 {edge_pixels}")
motion_xor = None
upper_xor = None
if pose_name != "neutral" and neutral_path and neutral_path.exists():
neutral = np.asarray(Image.open(neutral_path).convert("RGBA"))[:, :, 3] > 16
if neutral.shape == alpha.shape:
split = max(1, round(bbox[1] + body_h * 0.64))
upper_union = np.logical_or(neutral[:split], alpha[:split])
upper_xor = float(np.logical_xor(neutral[:split], alpha[:split]).sum() / max(1, upper_union.sum()))
lower_union = np.logical_or(neutral[split:], alpha[split:])
motion_xor = float(np.logical_xor(neutral[split:], alpha[split:]).sum() / max(1, lower_union.sum()))
if upper_xor > 0.12: failures.append(f"上半身漂移过大 {upper_xor:.3f}")
if motion_xor < 0.012: failures.append(f"腿脚动作不足 {motion_xor:.3f}")
qa = {"passed": not failures, "bbox": bbox, "canvas": [width, height], "body_width": body_w, "body_height": body_h, "center_error": round(center_error, 4), "upper_xor": upper_xor, "motion_xor": motion_xor, "failures": failures, "warnings": warnings}
if failures: raise RuntimeError("逐格QA失败" + "".join(failures))
return qa
def main(argv: Iterable[str]) -> int:
args = _parse_args(argv)
out_dir = args.out_dir.resolve()
@@ -1159,12 +1328,13 @@ def main(argv: Iterable[str]) -> int:
identity_prompt_path = prompt_dir / f"{skin_name}_identity.txt"
identity_path = identity_dir / f"{skin_name}_identity_reference.png"
identity_prompt_path.write_text(_identity_prompt(), encoding="utf-8")
_status(
args.status_json,
"identity",
"正在基于上传图片和whaleboy参考条生成角色身份母版",
)
_run_with_retries(
if args.phase != "actions":
_status(
args.status_json,
"identity",
"正在基于上传图片和海风少年单格参考生成角色身份母版",
)
_run_with_retries(
[
sys.executable,
str(args.novamailio_script),
@@ -1191,26 +1361,45 @@ def main(argv: Iterable[str]) -> int:
log_path=log_path,
env=child_env,
attempts=3,
retry_delay=10.0,
)
retry_delay=10.0,
)
elif not identity_path.exists():
raise FileNotFoundError("已确认的身份母版不存在,请先完成身份任务")
if args.phase == "identity":
result = {"ok": True, "phase": "identity", "identity_path": str(identity_path), "identity_prompt_path": str(identity_prompt_path), "log_path": str(log_path)}
args.result_json.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
_status(args.status_json, "identity_done", "身份母版已生成,请确认后继续动作")
return 0
_status(args.status_json, "prompt", "正在规划四方向8帧动作")
canonical_front_identity_path: Path | None = None
for direction in DIRECTIONS:
active_directions = (args.direction,) if args.phase == "pose" and args.direction else DIRECTIONS
for direction in active_directions:
pose_cutouts: dict[int, Path] = {}
pose_raw_paths: dict[int, Path] = {}
neutral_pose_path: Path | None = None
for existing_index, existing_name in enumerate(POSE_NAMES):
existing_raw = raw_dir / f"{skin_name}_{direction}_{existing_name}_source.png"
existing_cutout = cutout_dir / f"{skin_name}_{direction}_{existing_name}_cutout.png"
if existing_raw.exists(): pose_raw_paths[existing_index] = existing_raw
if existing_cutout.exists(): pose_cutouts[existing_index] = existing_cutout
neutral_pose_path: Path | None = pose_raw_paths.get(0)
lower_body_mask_path = (
pose_reference_dir / f"{direction}_lower_body_edit_mask.png"
)
for generation_step, pose_index in enumerate(
POSE_GENERATION_ORDER, start=1
):
if args.phase == "pose" and POSE_NAMES[pose_index] != args.pose:
continue
pose_name = POSE_NAMES[pose_index]
prompt_path = prompt_dir / f"{skin_name}_{direction}_{pose_name}.txt"
pose_reference_path = (
pose_reference_dir / f"{direction}_{pose_name}_reference.png"
)
layout_guide_path = (
pose_reference_dir / f"{direction}_{pose_name}_layout_guide.png"
)
raw_path = raw_dir / f"{skin_name}_{direction}_{pose_name}_source.png"
cutout_path = (
cutout_dir / f"{skin_name}_{direction}_{pose_name}_cutout.png"
@@ -1224,22 +1413,26 @@ def main(argv: Iterable[str]) -> int:
(0, 1, 3)[pose_index],
pose_reference_path,
)
_create_single_pose_layout_guide(layout_guide_path)
if pose_index == 1:
sibling_pose_path = pose_raw_paths.get(2)
if sibling_pose_path is None:
raise RuntimeError(
f"{direction} opposite-leg sibling pose was not generated first"
)
prompt_path.write_text(
_single_pose_from_sibling_prompt(direction, pose_index),
encoding="utf-8",
)
prompt_text = _single_pose_from_sibling_prompt(direction, pose_index)
motion_reference_path = sibling_pose_path
else:
prompt_path.write_text(
_single_pose_prompt(direction, pose_index), encoding="utf-8"
)
prompt_text = _single_pose_prompt(direction, pose_index)
motion_reference_path = pose_reference_path
if args.qa_feedback:
prompt_text += (
"\n\nTargeted repair feedback from the previous candidate:\n"
"- The previous candidate failed this QA check: "
+ args.qa_feedback.strip()
+ "\n- Correct only the reported layout or motion issue. Preserve the approved identity and requested action exactly.\n"
)
prompt_path.write_text(prompt_text, encoding="utf-8")
_status(
args.status_json,
@@ -1267,6 +1460,8 @@ def main(argv: Iterable[str]) -> int:
),
"--image",
str(motion_reference_path),
"--image",
str(layout_guide_path),
"--prompt-file",
str(prompt_path),
"--size",
@@ -1328,6 +1523,15 @@ def main(argv: Iterable[str]) -> int:
env=child_env,
)
pose_cutouts[pose_index] = cutout_path
layout_qa = _normalize_single_pose_cutout(cutout_path)
pose_qa = _validate_single_pose_cutout(cutout_path, pose_name, pose_cutouts.get(0))
pose_qa["layout"] = layout_qa
if args.phase == "pose":
result = {"ok": True, "phase": "pose", "direction": direction, "pose": pose_name, "raw_path": str(raw_path), "cutout_path": str(cutout_path), "preview_path": str(preview_path), "prompt_path": str(prompt_path), "layout_guide_path": str(layout_guide_path), "pose_reference_path": str(pose_reference_path), "qa": pose_qa}
args.result_json.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
_status(args.status_json, "pose_done", f"{direction} {pose_name} 动作已生成", direction=direction, pose=pose_name)
return 0
expanded_path = expanded_dir / f"{skin_name}_{direction}_8frame_cutout.png"
expand_command = [