#!/usr/bin/env python3 """Local web wrapper for the WhaleTown character generation pipeline.""" from __future__ import annotations import argparse import json import mimetypes import os import re import subprocess import threading import time import uuid import io import shutil from PIL import Image from http import HTTPStatus from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path from urllib.parse import unquote, urlparse TOOL_DIR = Path(__file__).resolve().parent BACKEND_DIR = TOOL_DIR.parent.parent WORKER = BACKEND_DIR / "scripts" / "skin_generation" / "generate_skin_from_prompt.py" DEFAULT_OUTPUT = BACKEND_DIR / "generated" / "character_maker" DEFAULT_SECRET_FILE = BACKEND_DIR.parent / "whale-town-front-v2" / ".secrets" / "novamailio.env" MAX_UPLOAD_BYTES = 8 * 1024 * 1024 ALLOWED_MIME = {"image/png": ".png", "image/jpeg": ".jpg", "image/webp": ".webp"} def load_local_secret() -> None: if os.getenv("NOVAMAILIO_API_KEY") or not DEFAULT_SECRET_FILE.is_file(): return for line in DEFAULT_SECRET_FILE.read_text("utf-8").splitlines(): if line.startswith("NOVAMAILIO_API_KEY="): value = line.split("=", 1)[1].strip().strip('"').strip("'") if value: os.environ["NOVAMAILIO_API_KEY"] = value return class CharacterMaker: def __init__(self, output_root: Path, python: str) -> None: self.output_root = output_root.resolve() self.python = python self.jobs: dict[str, dict[str, object]] = {} self.lock = threading.Lock() self.output_root.mkdir(parents=True, exist_ok=True) self._load_jobs() @staticmethod def _safe_name(value: str) -> str: value = re.sub(r"[^a-zA-Z0-9_\u4e00-\u9fff]+", "_", value.strip()) return re.sub(r"_+", "_", value).strip("_")[:40] or "custom_character" @staticmethod def _pose_plan() -> list[dict[str, object]]: plan = [] for direction in ("down", "up", "right", "left"): plan.extend( [ {"direction": direction, "pose": "neutral", "frame": 1, "depends_on": []}, {"direction": direction, "pose": "opposite_leg", "frame": 4, "depends_on": [f"{direction}:neutral"]}, {"direction": direction, "pose": "first_leg", "frame": 2, "depends_on": [f"{direction}:opposite_leg"]}, ] ) return plan def _load_jobs(self) -> None: for path in sorted(self.output_root.glob("*/job.json"), reverse=True): try: job = json.loads(path.read_text("utf-8")) if job.get("status") == "running": job.update(status="interrupted", message="工具曾退出,可重新开始生成") if job.get("poses"): for pose_state in job["poses"].values(): if pose_state.get("status") == "running": pose_state.update(status="interrupted", error="工具曾退出,可重新生成") self.jobs[str(job["id"])] = job except (OSError, ValueError, KeyError): continue def list_jobs(self) -> list[dict[str, object]]: with self.lock: jobs = list(self.jobs.values()) return sorted((self._public_job(job) for job in jobs), key=lambda x: str(x["created_at"]), reverse=True) def template_prompts(self) -> list[list[str]]: code = "import json; from pathlib import Path; p=Path(%r); ns={'__file__':str(p),'__name__':'prompt_templates'}; exec(compile(p.read_text(),str(p),'exec'),ns); poses=(0,1,0,2,0,1,0,2); print(json.dumps({'identity':ns['_identity_prompt'](),'frames':[[ns['_single_pose_prompt'](d,x) if x==0 else ns['_single_pose_from_sibling_prompt'](d,x) for x in poses] for d in ('down','up','right','left')]},ensure_ascii=False))" % str(WORKER) result = subprocess.run([self.python, "-c", code], cwd=BACKEND_DIR, capture_output=True, text=True) if result.returncode != 0: raise RuntimeError(result.stderr.strip() or "无法读取生成脚本提示词") return json.loads(result.stdout) def get_job(self, job_id: str) -> dict[str, object] | None: with self.lock: job = self.jobs.get(job_id) return self._public_job(job) if job else None def create_job(self, name: str, quality: str, mime: str, image: bytes, phase: str = "identity") -> dict[str, object]: if mime not in ALLOWED_MIME: raise ValueError("只支持 PNG、JPG 和 WebP 图片") if not 512 <= len(image) <= MAX_UPLOAD_BYTES: raise ValueError("参考图大小须在 512B 到 8MB 之间") if quality not in {"low", "medium", "high"}: raise ValueError("无效的质量档位") job_id = f"{time.strftime('%Y%m%d-%H%M%S')}-{uuid.uuid4().hex[:6]}" job_dir = self.output_root / job_id job_dir.mkdir(parents=True) source = job_dir / f"source{ALLOWED_MIME[mime]}" source.write_bytes(image) job: dict[str, object] = { "id": job_id, "name": self._safe_name(name), "quality": quality, "status": "queued", "stage": "queued", "message": "任务已创建", "created_at": time.strftime("%Y-%m-%d %H:%M:%S"), "source": source.name, "log": [], "phase": phase, "pose_plan": self._pose_plan(), } with self.lock: self.jobs[job_id] = job self._save(job) threading.Thread(target=self._run, args=(job_id,), daemon=True).start() return self._public_job(job) def _run(self, job_id: str) -> None: job = self.jobs[job_id] job_dir = self.output_root / job_id status_path = job_dir / "status.json" result_path = job_dir / "result.json" source = job_dir / str(job["source"]) cmd = [ self.python, str(WORKER), "--source-image", str(source), "--out-dir", str(job_dir), "--name", str(job["name"]), "--quality", str(job["quality"]), "--status-json", str(status_path), "--result-json", str(result_path), "--phase", str(job.get("phase", "all")), ] self._update(job, status="running", stage="start", message="正在启动角色生成流程") try: process = subprocess.Popen( cmd, cwd=BACKEND_DIR, env=os.environ.copy(), text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, bufsize=1, ) assert process.stdout is not None for line in process.stdout: line = line.strip() if line: logs = list(job.get("log", []))[-79:] + [line] updates: dict[str, object] = {"message": line, "log": logs} if status_path.exists(): try: status = json.loads(status_path.read_text("utf-8")) updates.update(stage=status.get("stage", job["stage"]), message=status.get("message", line)) except (OSError, ValueError): pass self._update(job, **updates) code = process.wait() result = json.loads(result_path.read_text("utf-8")) if result_path.exists() else {} if code != 0 or not result.get("ok"): raise RuntimeError(str(result.get("error") or f"生成进程退出码 {code}")) files = {} if result.get("phase") == "identity": identity_path = Path(str(result.get("identity_path", ""))) if identity_path.exists() and identity_path.is_relative_to(job_dir): files["identity_path"] = str(identity_path.relative_to(job_dir)) self._update(job, status="awaiting_confirmation", stage="identity_done", message="身份母版已生成,请确认后继续动作", files=files) return for key in ("spritesheet_path", "review_path", "feet_zoom_path"): path = Path(str(result.get(key, ""))) if path.exists() and path.is_relative_to(job_dir): files[key] = str(path.relative_to(job_dir)) self._update(job, status="completed", stage="done", message="角色生成完成", files=files) except Exception as exc: # Keep worker failures visible in the local UI. self._update(job, status="failed", stage="failed", message=f"生成失败:{exc}") def continue_job(self, job_id: str) -> dict[str, object] | None: job = self.jobs.get(job_id) if not job or job.get("status") == "running": return self._public_job(job) if job else None job["phase"] = "actions"; job["status"] = "action_ready"; job["stage"] = "action_ready"; job["message"] = "身份母版已确认,请逐个生成必要动作"; job["poses"] = job.get("poses", {}) self._save(job); return self._public_job(job) def generate_pose(self, job_id: str, direction: str, pose: str) -> dict[str, object] | None: job = self.jobs.get(job_id) if not job: return None if direction not in ("down", "up", "right", "left") or pose not in ("neutral", "opposite_leg", "first_leg"): raise ValueError("无效动作") poses = dict(job.get("poses", {})); key = f"{direction}:{pose}" dependencies = {"opposite_leg": f"{direction}:neutral", "first_leg": f"{direction}:opposite_leg"} dependency = dependencies.get(pose) if dependency and poses.get(dependency, {}).get("status") != "completed": raise ValueError(f"请先完成前置动作:{dependency}") if poses.get(key, {}).get("status") == "running": return self._public_job(job) poses[key] = {"status": "running", "attempt": 0}; job["poses"] = poses; self._save(job) threading.Thread(target=self._run_pose, args=(job_id, direction, pose), daemon=True).start(); return self._public_job(job) def _run_pose(self, job_id: str, direction: str, pose: str) -> None: job = self.jobs[job_id]; job_dir = self.output_root / job_id; key = f"{direction}:{pose}" result_path = job_dir / f"result_{direction}_{pose}.json"; status_path = job_dir / f"status_{direction}_{pose}.json" base_cmd = [self.python, str(WORKER), "--source-image", str(job_dir / str(job["source"])), "--out-dir", str(job_dir), "--name", str(job["name"]), "--quality", str(job["quality"]), "--result-json", str(result_path), "--status-json", str(status_path), "--phase", "pose", "--direction", direction, "--pose", pose] try: payload = {}; last_error = ""; archived_attempts: list[list[str]] = [] for attempt in range(1, 4): if attempt > 1: archived_attempts.append(self._archive_pose_attempt(job_dir, str(job["name"]), direction, pose, attempt - 1)) cmd = list(base_cmd) if last_error: cmd.extend(["--qa-feedback", last_error[:1200]]) result = subprocess.run(cmd, cwd=BACKEND_DIR, env=os.environ.copy(), capture_output=True, text=True) payload = json.loads(result_path.read_text("utf-8")) if result_path.exists() else {} if result.returncode == 0 and payload.get("ok"): break last_error = str(payload.get("error") or result.stderr[-1000:] or "动作生成失败") poses = dict(job.get("poses", {})); poses[key] = {"status": "running", "attempt": attempt, "last_error": last_error, "archived_attempts": archived_attempts}; job["poses"] = poses; self._save(job) else: raise RuntimeError(last_error) display = self._pose_display_path(job_id, str(job["name"]), direction, pose) preview = display or Path(str(payload["preview_path"])); inputs = {} for field in ("pose_reference_path", "layout_guide_path", "prompt_path"): path = Path(str(payload.get(field, ""))) if path.exists() and path.is_relative_to(job_dir): inputs[field] = str(path.relative_to(job_dir)) poses = dict(job.get("poses", {})); poses[key] = {"status": "completed", "attempt": attempt, "preview_url": f"/files/{job_id}/{preview.relative_to(job_dir)}", "qa": payload.get("qa", {}), "inputs": inputs, "archived_attempts": archived_attempts}; job["poses"] = poses; job["message"] = f"{direction} {pose} 已生成并通过逐格检查"; self._save(job) except Exception as exc: raw = job_dir / "raw" / f"{job['name']}_{direction}_{pose}_source.png" preview = job_dir / "cutout" / f"{job['name']}_{direction}_{pose}_preview.png" candidate = {"status": "failed", "error": str(exc), "candidate": True} display = self._pose_display_path(job_id, str(job["name"]), direction, pose) if display: candidate["preview_url"] = f"/files/{job_id}/{display.relative_to(job_dir)}" elif preview.exists(): candidate["preview_url"] = f"/files/{job_id}/{preview.relative_to(job_dir)}" if raw.exists(): candidate["raw_url"] = f"/files/{job_id}/{raw.relative_to(job_dir)}" if "archived_attempts" not in candidate: candidate["archived_attempts"] = locals().get("archived_attempts", []) poses = dict(job.get("poses", {})); poses[key] = candidate; job["poses"] = poses; job["message"] = f"动作生成失败,已保留候选预览:{exc}"; self._save(job) @staticmethod def _archive_pose_attempt(job_dir: Path, skin_name: str, direction: str, pose: str, attempt: int) -> list[str]: archive_dir = job_dir / "attempts" / f"{direction}_{pose}" / f"attempt_{attempt}" archived: list[str] = [] for folder, suffix in (("raw", "_source.png"), ("cutout", "_cutout.png"), ("cutout", "_mask.png"), ("cutout", "_preview.png"), ("cutout", "_display.png")): source = job_dir / folder / f"{skin_name}_{direction}_{pose}{suffix}" if not source.is_file(): continue archive_dir.mkdir(parents=True, exist_ok=True) target = archive_dir / source.name shutil.copy2(source, target) archived.append(str(target.relative_to(job_dir))) return archived def _pose_display_path(self, job_id: str, skin_name: str, direction: str, pose: str) -> Path | None: """Create a single-character preview from the transparent cutout. The cutout tool's preview is a two-panel diagnostic image, so it is kept for QA but never shown as the user's generated pose preview. """ job_dir = self.output_root / job_id cutout = job_dir / "cutout" / f"{skin_name}_{direction}_{pose}_cutout.png" display = job_dir / "cutout" / f"{skin_name}_{direction}_{pose}_display.png" if cutout.is_file() and ( not display.is_file() or display.stat().st_mtime < cutout.stat().st_mtime ): try: with Image.open(cutout).convert("RGBA") as source: canvas = Image.new("RGBA", source.size, (255, 0, 255, 255)) canvas.alpha_composite(source) canvas.convert("RGB").save(display, format="PNG") except (OSError, ValueError): return None if display.is_file(): return display raw = job_dir / "raw" / f"{skin_name}_{direction}_{pose}_source.png" return raw if raw.is_file() else None def _update(self, job: dict[str, object], **updates: object) -> None: with self.lock: job.update(updates) self._save(job) def _save(self, job: dict[str, object]) -> None: path = self.output_root / str(job["id"]) / "job.json" path.write_text(json.dumps(job, ensure_ascii=False, indent=2), "utf-8") def _public_job(self, job: dict[str, object]) -> dict[str, object]: data = dict(job) job_id = str(job["id"]) data["source_url"] = f"/files/{job_id}/{job['source']}" data["file_urls"] = { key: f"/files/{job_id}/{value}" for key, value in dict(job.get("files", {})).items() } data["prompts"] = job.get("prompts", []) data["pose_plan"] = job.get("pose_plan") or self._pose_plan() # Recover previews from disk for jobs interrupted after image generation. # Older jobs may have raw/cutout files but no pose URL persisted in job.json. poses = {str(key): dict(value) for key, value in dict(job.get("poses") or {}).items()} skin_name = str(job.get("name", "custom_character")) for direction in ("down", "up", "right", "left"): for pose in ("neutral", "first_leg", "opposite_leg"): key = f"{direction}:{pose}" state = poses.setdefault(key, {}) preview = self._pose_display_path(job_id, skin_name, direction, pose) raw = self.output_root / job_id / "raw" / f"{skin_name}_{direction}_{pose}_source.png" if preview and preview.is_file(): state.update(preview_url=f"/files/{job_id}/{preview.relative_to(self.output_root / job_id)}", candidate=state.get("status") != "completed") elif state.get("preview_url") or state.get("raw_url"): continue elif raw.is_file(): state.update(raw_url=f"/files/{job_id}/{raw.relative_to(self.output_root / job_id)}", candidate=state.get("status") != "completed") if poses: data["poses"] = poses data["frame_plan"] = [{"frame": i + 1, "source_frame": (1, 2, 1, 4, 1, 2, 1, 4)[i], "generated": i in (0, 1, 3)} for i in range(8)] data["reference_urls"] = [f"/references/human_whale_reference_{d}.png" for d in ("down", "up", "right", "left")] data["reference_cell_urls"] = [[f"/references/cell/{d}/{f}.png" for f in (1, 2, 4)] for d in ("down", "up", "right", "left")] return data class Handler(SimpleHTTPRequestHandler): maker: CharacterMaker def do_GET(self) -> None: path = urlparse(self.path).path if path == "/api/health": self._json({"ok": True, "worker": WORKER.exists(), "api_key": bool(os.getenv("NOVAMAILIO_API_KEY"))}) return if path == "/api/jobs": self._json({"jobs": self.maker.list_jobs()}) return if path == "/api/prompt-templates": try: templates = self.maker.template_prompts() self._json({"identity_prompt": templates["identity"], "prompts": templates["frames"], "identity_reference_url": "/references/whaleboy_identity_single.png"}) except Exception as exc: self._json({"error": str(exc)}, HTTPStatus.INTERNAL_SERVER_ERROR) return match = re.fullmatch(r"/api/jobs/([\w-]+)", path) if match: job = self.maker.get_job(match.group(1)) self._json(job or {"error": "任务不存在"}, HTTPStatus.OK if job else HTTPStatus.NOT_FOUND) return if path.startswith("/files/"): self._serve_output(path) return if path.startswith("/references/"): cell_match = re.fullmatch(r"/references/cell/(down|up|right|left)/(1|2|4)\.png", path) if cell_match: direction, frame = cell_match.groups() source = BACKEND_DIR / "scripts" / "skin_generation" / "references" / f"human_whale_reference_{direction}.png" with Image.open(source).convert("RGB") as image: cell_width = image.width // 8 cell = image.crop(((int(frame) - 1) * cell_width, 0, int(frame) * cell_width, image.height)) pixels = cell.load(); xs=[]; ys=[] for y in range(cell.height): for x in range(cell.width): red, green, blue = pixels[x, y] if not (red > 220 and green < 80 and blue > 180): xs.append(x); ys.append(y) box = (max(0, min(xs)-8), max(0, min(ys)-8), min(cell.width, max(xs)+9), min(cell.height, max(ys)+9)) cropped = cell.crop(box); scale = 700 / max(1, max(ys)-min(ys)+1); cropped = cropped.resize((round(cropped.width*scale), round(cropped.height*scale)), Image.Resampling.LANCZOS) canvas = Image.new("RGB", (1024, 1024), (255, 0, 255)); canvas.paste(cropped, ((1024-cropped.width)//2, (1024-cropped.height)//2)); output = io.BytesIO(); canvas.save(output, format="PNG"); data = output.getvalue() self.send_response(200); self.send_header("Content-Type", "image/png"); self.send_header("Content-Length", str(len(data))); self.send_header("Cache-Control", "no-store"); self.end_headers(); self.wfile.write(data); return name = Path(unquote(path).split("/")[-1]).name target = BACKEND_DIR / "scripts" / "skin_generation" / "references" / name if target.is_file(): data = target.read_bytes(); self.send_response(200); self.send_header("Content-Type", "image/png"); self.send_header("Content-Length", str(len(data))); self.end_headers(); self.wfile.write(data); return self.send_error(404); return if path in {"/", "/index.html"}: self.path = "/index.html" return super().do_GET() def do_POST(self) -> None: request_path = urlparse(self.path).path pose_match = re.fullmatch(r"/api/jobs/([\w-]+)/poses", request_path) if pose_match: try: length = int(self.headers.get("Content-Length", "0")); payload = json.loads(self.rfile.read(length)); job = self.maker.generate_pose(pose_match.group(1), str(payload.get("direction", "")), str(payload.get("pose", ""))); self._json(job or {"error": "任务不存在"}, HTTPStatus.ACCEPTED if job else HTTPStatus.NOT_FOUND) except (ValueError, json.JSONDecodeError) as exc: self._json({"error": str(exc)}, HTTPStatus.BAD_REQUEST) return if request_path != "/api/jobs": self._json({"error": "接口不存在"}, HTTPStatus.NOT_FOUND) return try: length = int(self.headers.get("Content-Length", "0")) if length > MAX_UPLOAD_BYTES + 4096: raise ValueError("上传内容不能超过 8MB") payload = json.loads(self.rfile.read(length)) import base64 image = base64.b64decode(str(payload.get("image", "")), validate=True) job = self.maker.create_job( str(payload.get("name", "")), str(payload.get("quality", "medium")), str(payload.get("mime", "")), image, str(payload.get("phase", "identity")), ) self._json(job, HTTPStatus.CREATED) except (ValueError, json.JSONDecodeError) as exc: self._json({"error": str(exc)}, HTTPStatus.BAD_REQUEST) def do_PUT(self) -> None: match = re.fullmatch(r"/api/jobs/([\w-]+)/continue", urlparse(self.path).path) if match: job = self.maker.continue_job(match.group(1)); self._json(job or {"error": "任务不存在"}, HTTPStatus.OK if job else HTTPStatus.NOT_FOUND); return self._json({"error": "接口不存在"}, HTTPStatus.NOT_FOUND) def translate_path(self, path: str) -> str: relative = urlparse(path).path.lstrip("/") return str((TOOL_DIR / relative).resolve()) def _serve_output(self, request_path: str) -> None: relative = unquote(request_path.removeprefix("/files/")) target = (self.maker.output_root / relative).resolve() if not target.is_relative_to(self.maker.output_root) or not target.is_file(): self.send_error(HTTPStatus.NOT_FOUND) return data = target.read_bytes() self.send_response(HTTPStatus.OK) self.send_header("Content-Type", mimetypes.guess_type(target.name)[0] or "application/octet-stream") self.send_header("Content-Length", str(len(data))) self.send_header("Cache-Control", "no-store") self.end_headers() self.wfile.write(data) def _json(self, payload: object, status: HTTPStatus = HTTPStatus.OK) -> None: data = json.dumps(payload, ensure_ascii=False).encode("utf-8") self.send_response(status) self.send_header("Content-Type", "application/json; charset=utf-8") self.send_header("Content-Length", str(len(data))) self.send_header("Cache-Control", "no-store") self.end_headers() self.wfile.write(data) def log_message(self, fmt: str, *args: object) -> None: print(f"[character-maker] {self.address_string()} {fmt % args}") def main() -> None: load_local_secret() parser = argparse.ArgumentParser(description="WhaleTown local character maker") parser.add_argument("--host", default="127.0.0.1") parser.add_argument("--port", type=int, default=8765) parser.add_argument("--python", default=os.getenv("SKIN_GENERATION_PYTHON", "python3")) parser.add_argument("--output", type=Path, default=DEFAULT_OUTPUT) args = parser.parse_args() Handler.maker = CharacterMaker(args.output, args.python) server = ThreadingHTTPServer((args.host, args.port), Handler) print(f"角色工坊已启动:http://{args.host}:{args.port}") server.serve_forever() if __name__ == "__main__": main()