Initial commit: AIGame Unity client with URP, HybridCLR, YooAsset and tooling.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,612 @@
|
||||
#!/usr/bin/env python3
|
||||
"""gpt-image-2.5 文生图 / 图生图工具。
|
||||
|
||||
支持:
|
||||
- AI / Agent CLI 调用(参数或 JSON)
|
||||
- 流程回调(HTTP webhook / 状态文件 / NDJSON 事件流)
|
||||
- 指定本地生成保存路径
|
||||
|
||||
接口文档: Doc/gpt-image_doc.md
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import json
|
||||
import mimetypes
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Dict, List, Optional, Sequence, Union
|
||||
|
||||
DEFAULT_MODEL = "gpt-image-2.5"
|
||||
VIP_MODEL = "gpt-image-2-vip"
|
||||
DEFAULT_API_KEY = "sk-b69877bd82c6412ca4872351f428d580"
|
||||
DEFAULT_BASE_URL = "https://grsai.dakka.com.cn" # 国内节点;全球: https://grsaiapi.com
|
||||
DEFAULT_ASPECT_RATIO = "1024x1024"
|
||||
DEFAULT_QUALITY = "auto"
|
||||
DEFAULT_REPLY_TYPE = "json"
|
||||
POLL_INTERVAL_SEC = 2.0
|
||||
POLL_TIMEOUT_SEC = 300.0
|
||||
# 标准模型仅支持约 1K;任一边超过该阈值需改用 VIP 像素尺寸
|
||||
MAX_STANDARD_EDGE = 1024
|
||||
VIP_MODELS = frozenset(
|
||||
{
|
||||
"gpt-image-2-vip",
|
||||
"gpt-image-2.5-flare",
|
||||
"gpt-image-2.5-sunburst",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class GenerateError(RuntimeError):
|
||||
"""生成失败。"""
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
||||
|
||||
|
||||
def _resolve_api_key(explicit: Optional[str] = None) -> str:
|
||||
return (
|
||||
explicit
|
||||
or os.environ.get("GRSAI_API_KEY")
|
||||
or os.environ.get("GPT_IMAGE_API_KEY")
|
||||
or DEFAULT_API_KEY
|
||||
)
|
||||
|
||||
|
||||
def _normalize_base_url(base_url: str) -> str:
|
||||
return base_url.rstrip("/")
|
||||
|
||||
|
||||
def _guess_mime(path: Path) -> str:
|
||||
mime, _ = mimetypes.guess_type(str(path))
|
||||
return mime or "application/octet-stream"
|
||||
|
||||
|
||||
def _file_to_data_url(path: Path) -> str:
|
||||
data = path.read_bytes()
|
||||
b64 = base64.b64encode(data).decode("ascii")
|
||||
return f"data:{_guess_mime(path)};base64,{b64}"
|
||||
|
||||
|
||||
def parse_pixel_size(aspect_ratio: str) -> Optional[tuple[int, int]]:
|
||||
"""解析 '2048x2048' / '2048X1152';比例字符串(如 16:9)返回 None。"""
|
||||
text = (aspect_ratio or "").strip().lower().replace(" ", "")
|
||||
if "x" not in text:
|
||||
return None
|
||||
left, _, right = text.partition("x")
|
||||
if not left.isdigit() or not right.isdigit():
|
||||
return None
|
||||
return int(left), int(right)
|
||||
|
||||
|
||||
def needs_vip_for_size(aspect_ratio: str) -> bool:
|
||||
"""任一边 >1024 的像素尺寸需要 VIP 模型。"""
|
||||
size = parse_pixel_size(aspect_ratio)
|
||||
if size is None:
|
||||
return False
|
||||
w, h = size
|
||||
return w > MAX_STANDARD_EDGE or h > MAX_STANDARD_EDGE
|
||||
|
||||
|
||||
def resolve_model_for_size(model: str, aspect_ratio: str) -> str:
|
||||
"""大于 1K 时:已是 VIP 系列则保留;否则自动升级到 gpt-image-2-vip。"""
|
||||
if not needs_vip_for_size(aspect_ratio):
|
||||
return model
|
||||
if model in VIP_MODELS:
|
||||
return model
|
||||
return VIP_MODEL
|
||||
|
||||
|
||||
def normalize_image_ref(ref: str) -> str:
|
||||
"""将本地路径转为 data URL;URL / base64 / data URL 原样返回。"""
|
||||
text = ref.strip()
|
||||
if not text:
|
||||
raise GenerateError("空的参考图路径")
|
||||
if text.startswith(("http://", "https://", "data:")):
|
||||
return text
|
||||
# 纯 base64(无 data: 前缀)
|
||||
if len(text) > 256 and all(
|
||||
c.isalnum() or c in "+/=\n\r" for c in text[:200]
|
||||
) and not Path(text).exists():
|
||||
return text
|
||||
|
||||
path = Path(text).expanduser().resolve()
|
||||
if not path.is_file():
|
||||
raise GenerateError(f"参考图不存在: {path}")
|
||||
return _file_to_data_url(path)
|
||||
|
||||
|
||||
def http_json(
|
||||
method: str,
|
||||
url: str,
|
||||
*,
|
||||
headers: Optional[Dict[str, str]] = None,
|
||||
body: Optional[Dict[str, Any]] = None,
|
||||
timeout: float = 300.0,
|
||||
) -> Dict[str, Any]:
|
||||
data = None
|
||||
req_headers = {"Accept": "application/json", **(headers or {})}
|
||||
if body is not None:
|
||||
data = json.dumps(body, ensure_ascii=False).encode("utf-8")
|
||||
req_headers["Content-Type"] = "application/json"
|
||||
|
||||
request = urllib.request.Request(url, data=data, headers=req_headers, method=method)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=timeout) as resp:
|
||||
raw = resp.read().decode("utf-8")
|
||||
if not raw.strip():
|
||||
return {}
|
||||
return json.loads(raw)
|
||||
except urllib.error.HTTPError as exc:
|
||||
err_body = exc.read().decode("utf-8", errors="replace")
|
||||
try:
|
||||
payload = json.loads(err_body) if err_body else {}
|
||||
except json.JSONDecodeError:
|
||||
payload = {"error": err_body}
|
||||
message = payload.get("error") or err_body or str(exc)
|
||||
raise GenerateError(f"HTTP {exc.code}: {message}") from exc
|
||||
except urllib.error.URLError as exc:
|
||||
raise GenerateError(f"网络错误: {exc}") from exc
|
||||
|
||||
|
||||
def download_file(url: str, dest: Path, timeout: float = 120.0) -> Path:
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
request = urllib.request.Request(url, headers={"User-Agent": "AIGame-gpt-image/1.0"})
|
||||
with urllib.request.urlopen(request, timeout=timeout) as resp:
|
||||
dest.write_bytes(resp.read())
|
||||
return dest
|
||||
|
||||
|
||||
class CallbackHub:
|
||||
"""向 stdout / 文件 / HTTP webhook 广播流程事件。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
callback_url: Optional[str] = None,
|
||||
callback_file: Optional[Union[str, Path]] = None,
|
||||
quiet: bool = False,
|
||||
) -> None:
|
||||
self.callback_url = callback_url
|
||||
self.callback_file = Path(callback_file) if callback_file else None
|
||||
self.quiet = quiet
|
||||
self.events: List[Dict[str, Any]] = []
|
||||
|
||||
def emit(self, event: str, **payload: Any) -> Dict[str, Any]:
|
||||
record = {
|
||||
"event": event,
|
||||
"ts": _now_iso(),
|
||||
**payload,
|
||||
}
|
||||
self.events.append(record)
|
||||
|
||||
if not self.quiet:
|
||||
# NDJSON,便于 AI / 流程引擎按行解析
|
||||
print(json.dumps(record, ensure_ascii=False), flush=True)
|
||||
|
||||
if self.callback_file:
|
||||
self.callback_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
self.callback_file.write_text(
|
||||
json.dumps(
|
||||
{"latest": record, "history": self.events},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
if self.callback_url:
|
||||
try:
|
||||
http_json("POST", self.callback_url, body=record, timeout=30.0)
|
||||
except Exception as exc: # noqa: BLE001 — 回调失败不阻断主流程
|
||||
if not self.quiet:
|
||||
warn = {
|
||||
"event": "callback_error",
|
||||
"ts": _now_iso(),
|
||||
"error": str(exc),
|
||||
"callback_url": self.callback_url,
|
||||
}
|
||||
print(json.dumps(warn, ensure_ascii=False), flush=True)
|
||||
|
||||
return record
|
||||
|
||||
|
||||
def query_result(
|
||||
base_url: str,
|
||||
task_id: str,
|
||||
api_key: str,
|
||||
timeout: float = 60.0,
|
||||
) -> Dict[str, Any]:
|
||||
url = f"{_normalize_base_url(base_url)}/v1/api/result?{urllib.parse.urlencode({'id': task_id})}"
|
||||
return http_json(
|
||||
"GET",
|
||||
url,
|
||||
headers={"Authorization": f"Bearer {api_key}"},
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
|
||||
def submit_generate(
|
||||
*,
|
||||
prompt: str,
|
||||
api_key: str,
|
||||
base_url: str = DEFAULT_BASE_URL,
|
||||
model: str = DEFAULT_MODEL,
|
||||
images: Optional[Sequence[str]] = None,
|
||||
aspect_ratio: str = DEFAULT_ASPECT_RATIO,
|
||||
quality: str = DEFAULT_QUALITY,
|
||||
reply_type: str = DEFAULT_REPLY_TYPE,
|
||||
background: Optional[str] = None,
|
||||
mask: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
image_refs = [normalize_image_ref(x) for x in (images or [])]
|
||||
body: Dict[str, Any] = {
|
||||
"model": model,
|
||||
"prompt": prompt,
|
||||
"images": image_refs,
|
||||
"aspectRatio": aspect_ratio,
|
||||
"quality": quality,
|
||||
"replyType": reply_type,
|
||||
}
|
||||
if background:
|
||||
body["background"] = background
|
||||
if mask:
|
||||
body["mask"] = normalize_image_ref(mask)
|
||||
|
||||
url = f"{_normalize_base_url(base_url)}/v1/api/generate"
|
||||
return http_json(
|
||||
"POST",
|
||||
url,
|
||||
headers={"Authorization": f"Bearer {api_key}"},
|
||||
body=body,
|
||||
timeout=300.0,
|
||||
)
|
||||
|
||||
|
||||
def wait_until_done(
|
||||
*,
|
||||
base_url: str,
|
||||
api_key: str,
|
||||
initial: Dict[str, Any],
|
||||
reply_type: str,
|
||||
callbacks: CallbackHub,
|
||||
poll_interval: float = POLL_INTERVAL_SEC,
|
||||
poll_timeout: float = POLL_TIMEOUT_SEC,
|
||||
) -> Dict[str, Any]:
|
||||
status = (initial.get("status") or "").lower()
|
||||
if status in {"succeeded", "failed", "violation"} or (
|
||||
reply_type == "json" and initial.get("results")
|
||||
):
|
||||
return initial
|
||||
|
||||
task_id = initial.get("id")
|
||||
if not task_id:
|
||||
raise GenerateError(f"响应缺少任务 id: {initial}")
|
||||
|
||||
deadline = time.time() + poll_timeout
|
||||
last_progress = initial.get("progress")
|
||||
current = initial
|
||||
|
||||
while time.time() < deadline:
|
||||
current = query_result(base_url, task_id, api_key)
|
||||
status = (current.get("status") or "").lower()
|
||||
progress = current.get("progress")
|
||||
if progress != last_progress:
|
||||
callbacks.emit(
|
||||
"progress",
|
||||
id=task_id,
|
||||
status=status,
|
||||
progress=progress,
|
||||
)
|
||||
last_progress = progress
|
||||
|
||||
if status == "succeeded" or current.get("results"):
|
||||
return current
|
||||
if status in {"failed", "violation"}:
|
||||
raise GenerateError(current.get("error") or f"任务失败: {current}")
|
||||
|
||||
time.sleep(poll_interval)
|
||||
|
||||
raise GenerateError(f"等待超时({poll_timeout}s),最后状态: {current}")
|
||||
|
||||
|
||||
def resolve_output_paths(
|
||||
output: Optional[str],
|
||||
count: int,
|
||||
) -> List[Path]:
|
||||
if not output:
|
||||
stamp = time.strftime("%Y%m%d_%H%M%S")
|
||||
output = str(Path("output") / f"gpt_image_{stamp}.png")
|
||||
|
||||
path = Path(output).expanduser()
|
||||
if count <= 1:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
return [path]
|
||||
|
||||
# 多图:若给的是文件路径,则追加 _1/_2;若是目录则编号保存
|
||||
if path.suffix:
|
||||
stem, suffix = path.stem, path.suffix
|
||||
parent = path.parent
|
||||
parent.mkdir(parents=True, exist_ok=True)
|
||||
return [parent / f"{stem}_{i + 1}{suffix}" for i in range(count)]
|
||||
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
return [path / f"image_{i + 1}.png" for i in range(count)]
|
||||
|
||||
|
||||
def save_results(
|
||||
result: Dict[str, Any],
|
||||
output: Optional[str],
|
||||
) -> List[Dict[str, Any]]:
|
||||
results = result.get("results") or []
|
||||
if not results:
|
||||
raise GenerateError(f"成功响应但无 results: {result}")
|
||||
|
||||
paths = resolve_output_paths(output, len(results))
|
||||
saved: List[Dict[str, Any]] = []
|
||||
for item, dest in zip(results, paths):
|
||||
url = item.get("url")
|
||||
if not url:
|
||||
raise GenerateError(f"结果缺少 url: {item}")
|
||||
download_file(url, dest)
|
||||
saved.append({"url": url, "path": str(dest.resolve())})
|
||||
return saved
|
||||
|
||||
|
||||
def generate(
|
||||
*,
|
||||
prompt: str,
|
||||
output: Optional[str] = None,
|
||||
images: Optional[Sequence[str]] = None,
|
||||
model: str = DEFAULT_MODEL,
|
||||
api_key: Optional[str] = None,
|
||||
base_url: str = DEFAULT_BASE_URL,
|
||||
aspect_ratio: str = DEFAULT_ASPECT_RATIO,
|
||||
quality: str = DEFAULT_QUALITY,
|
||||
reply_type: str = DEFAULT_REPLY_TYPE,
|
||||
background: Optional[str] = None,
|
||||
mask: Optional[str] = None,
|
||||
callback_url: Optional[str] = None,
|
||||
callback_file: Optional[str] = None,
|
||||
poll_interval: float = POLL_INTERVAL_SEC,
|
||||
poll_timeout: float = POLL_TIMEOUT_SEC,
|
||||
quiet: bool = False,
|
||||
on_event: Optional[Callable[[Dict[str, Any]], None]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""执行一次文/图生图,返回结构化结果(可供流程编排直接使用)。"""
|
||||
key = _resolve_api_key(api_key)
|
||||
model = resolve_model_for_size(model, aspect_ratio)
|
||||
callbacks = CallbackHub(
|
||||
callback_url=callback_url,
|
||||
callback_file=callback_file,
|
||||
quiet=quiet,
|
||||
)
|
||||
if on_event:
|
||||
original_emit = callbacks.emit
|
||||
|
||||
def emit_with_hook(event: str, **payload: Any) -> Dict[str, Any]:
|
||||
record = original_emit(event, **payload)
|
||||
on_event(record)
|
||||
return record
|
||||
|
||||
callbacks.emit = emit_with_hook # type: ignore[method-assign]
|
||||
|
||||
callbacks.emit(
|
||||
"started",
|
||||
model=model,
|
||||
prompt=prompt,
|
||||
output=output,
|
||||
aspect_ratio=aspect_ratio,
|
||||
image_count=len(images or []),
|
||||
reply_type=reply_type,
|
||||
)
|
||||
|
||||
try:
|
||||
submitted = submit_generate(
|
||||
prompt=prompt,
|
||||
api_key=key,
|
||||
base_url=base_url,
|
||||
model=model,
|
||||
images=images,
|
||||
aspect_ratio=aspect_ratio,
|
||||
quality=quality,
|
||||
reply_type=reply_type,
|
||||
background=background,
|
||||
mask=mask,
|
||||
)
|
||||
callbacks.emit(
|
||||
"submitted",
|
||||
id=submitted.get("id"),
|
||||
status=submitted.get("status"),
|
||||
progress=submitted.get("progress"),
|
||||
)
|
||||
|
||||
# async / 未完成时轮询
|
||||
effective_reply = reply_type
|
||||
if (submitted.get("status") or "").lower() == "running" and not submitted.get(
|
||||
"results"
|
||||
):
|
||||
effective_reply = "async"
|
||||
|
||||
final = wait_until_done(
|
||||
base_url=base_url,
|
||||
api_key=key,
|
||||
initial=submitted,
|
||||
reply_type=effective_reply,
|
||||
callbacks=callbacks,
|
||||
poll_interval=poll_interval,
|
||||
poll_timeout=poll_timeout,
|
||||
)
|
||||
|
||||
status = (final.get("status") or "").lower()
|
||||
if status in {"failed", "violation"}:
|
||||
raise GenerateError(final.get("error") or f"任务失败: {final}")
|
||||
|
||||
saved = save_results(final, output)
|
||||
result = {
|
||||
"ok": True,
|
||||
"id": final.get("id"),
|
||||
"status": final.get("status") or "succeeded",
|
||||
"progress": final.get("progress", 100),
|
||||
"model": model,
|
||||
"prompt": prompt,
|
||||
"files": saved,
|
||||
"raw": final,
|
||||
}
|
||||
callbacks.emit("succeeded", **{k: v for k, v in result.items() if k != "raw"})
|
||||
return result
|
||||
except Exception as exc:
|
||||
err = {"ok": False, "error": str(exc)}
|
||||
callbacks.emit("failed", **err)
|
||||
raise
|
||||
|
||||
|
||||
def _load_json_arg(raw: Optional[str]) -> Dict[str, Any]:
|
||||
if not raw:
|
||||
return {}
|
||||
if raw == "-":
|
||||
return json.load(sys.stdin)
|
||||
path = Path(raw)
|
||||
if path.is_file():
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
return json.loads(raw)
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
p = argparse.ArgumentParser(
|
||||
prog="gpt_image_generate",
|
||||
description="gpt-image-2.5 文生图/图生图(支持 AI 调用与流程回调)",
|
||||
)
|
||||
p.add_argument("--prompt", "-p", help="提示词")
|
||||
p.add_argument(
|
||||
"--image",
|
||||
"-i",
|
||||
action="append",
|
||||
dest="images",
|
||||
default=[],
|
||||
help="参考图(可多次;本地路径 / URL / base64)",
|
||||
)
|
||||
p.add_argument(
|
||||
"--output",
|
||||
"-o",
|
||||
help="生成图保存路径(文件或目录)。多图时自动编号",
|
||||
)
|
||||
p.add_argument(
|
||||
"--model",
|
||||
default=DEFAULT_MODEL,
|
||||
help=f"模型,默认 {DEFAULT_MODEL};像素任一边>1024 时自动升为 {VIP_MODEL}",
|
||||
)
|
||||
p.add_argument("--api-key", default=None, help="API Key,默认读环境变量或内置配置")
|
||||
p.add_argument(
|
||||
"--base-url",
|
||||
default=DEFAULT_BASE_URL,
|
||||
help="API 节点,默认国内节点",
|
||||
)
|
||||
p.add_argument(
|
||||
"--aspect-ratio",
|
||||
default=DEFAULT_ASPECT_RATIO,
|
||||
help='比例或像素,如 "16:9" / "1024x1024";>1K 请用像素如 "2048x2048"(自动 VIP)',
|
||||
)
|
||||
p.add_argument("--quality", default=DEFAULT_QUALITY, help="质量: auto/low/medium/high/...")
|
||||
p.add_argument(
|
||||
"--reply-type",
|
||||
default=DEFAULT_REPLY_TYPE,
|
||||
choices=["json", "stream", "async"],
|
||||
help="回复类型",
|
||||
)
|
||||
p.add_argument("--background", default=None, help="背景参数,如 transparent(VIP 模型)")
|
||||
p.add_argument("--mask", default=None, help="mask 图路径或 URL")
|
||||
p.add_argument(
|
||||
"--callback-url",
|
||||
default=None,
|
||||
help="流程回调 URL:每个事件 POST JSON",
|
||||
)
|
||||
p.add_argument(
|
||||
"--callback-file",
|
||||
default=None,
|
||||
help="流程回调状态文件(持续覆盖写入 latest/history)",
|
||||
)
|
||||
p.add_argument("--poll-interval", type=float, default=POLL_INTERVAL_SEC)
|
||||
p.add_argument("--poll-timeout", type=float, default=POLL_TIMEOUT_SEC)
|
||||
p.add_argument(
|
||||
"--json",
|
||||
dest="json_input",
|
||||
default=None,
|
||||
help='JSON 参数(对象字符串 / 文件路径 / "-" 读 stdin)',
|
||||
)
|
||||
p.add_argument(
|
||||
"--result-only",
|
||||
action="store_true",
|
||||
help="只输出最终结果 JSON(抑制中间 NDJSON 事件到 stdout,仍写回调)",
|
||||
)
|
||||
p.add_argument(
|
||||
"--quiet",
|
||||
action="store_true",
|
||||
help="不向 stdout 打印事件流(最终结果仍打印)",
|
||||
)
|
||||
return p
|
||||
|
||||
|
||||
def main(argv: Optional[Sequence[str]] = None) -> int:
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(argv)
|
||||
payload = _load_json_arg(args.json_input)
|
||||
|
||||
prompt = payload.get("prompt") or args.prompt
|
||||
if not prompt:
|
||||
parser.error("必须提供 --prompt 或 JSON.prompt")
|
||||
|
||||
images = payload.get("images") or args.images or []
|
||||
if isinstance(images, str):
|
||||
images = [images]
|
||||
|
||||
output = payload.get("output") or args.output
|
||||
quiet_events = bool(args.quiet or args.result_only or payload.get("quiet"))
|
||||
|
||||
try:
|
||||
result = generate(
|
||||
prompt=prompt,
|
||||
output=output,
|
||||
images=images,
|
||||
model=payload.get("model") or args.model,
|
||||
api_key=payload.get("api_key") or args.api_key,
|
||||
base_url=payload.get("base_url") or args.base_url,
|
||||
aspect_ratio=payload.get("aspectRatio")
|
||||
or payload.get("aspect_ratio")
|
||||
or args.aspect_ratio,
|
||||
quality=payload.get("quality") or args.quality,
|
||||
reply_type=payload.get("replyType")
|
||||
or payload.get("reply_type")
|
||||
or args.reply_type,
|
||||
background=payload.get("background") or args.background,
|
||||
mask=payload.get("mask") or args.mask,
|
||||
callback_url=payload.get("callback_url") or args.callback_url,
|
||||
callback_file=payload.get("callback_file") or args.callback_file,
|
||||
poll_interval=float(payload.get("poll_interval") or args.poll_interval),
|
||||
poll_timeout=float(payload.get("poll_timeout") or args.poll_timeout),
|
||||
quiet=quiet_events,
|
||||
)
|
||||
except GenerateError as exc:
|
||||
err = {"ok": False, "error": str(exc)}
|
||||
print(json.dumps(err, ensure_ascii=False), flush=True)
|
||||
return 1
|
||||
except Exception as exc: # noqa: BLE001
|
||||
err = {"ok": False, "error": str(exc)}
|
||||
print(json.dumps(err, ensure_ascii=False), flush=True)
|
||||
return 2
|
||||
|
||||
# 最终结果单独一行,AI / 流程可取最后一行或 --result-only 模式
|
||||
print(json.dumps(result, ensure_ascii=False), flush=True)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,14 @@
|
||||
"""腾讯混元 3D(https://3d.hunyuan.tencent.com)网页自动化:微信登录 + 图生3D。"""
|
||||
|
||||
from .auth import ensure_login, is_logged_in, logout, start_wechat_login_blocking, status
|
||||
from .client import FACE_LABELS, generate_from_image
|
||||
|
||||
__all__ = [
|
||||
"ensure_login",
|
||||
"is_logged_in",
|
||||
"logout",
|
||||
"start_wechat_login_blocking",
|
||||
"status",
|
||||
"FACE_LABELS",
|
||||
"generate_from_image",
|
||||
]
|
||||
@@ -0,0 +1,240 @@
|
||||
"""WeChat login session for https://3d.hunyuan.tencent.com via Playwright.
|
||||
|
||||
CLI 友好:同步阻塞扫码登录(非后台线程)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
HUNYUAN_URL = "https://3d.hunyuan.tencent.com/"
|
||||
LOGIN_TIMEOUT_SEC = 180
|
||||
|
||||
UpdateFn = Callable[..., None]
|
||||
|
||||
|
||||
def _tools_root() -> Path:
|
||||
return Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
def session_dir() -> Path:
|
||||
override = os.environ.get("HUNYUAN_SESSION_DIR", "").strip()
|
||||
if override:
|
||||
return Path(override).expanduser().resolve()
|
||||
return _tools_root() / "data" / "hunyuan"
|
||||
|
||||
|
||||
def storage_state_path() -> Path:
|
||||
return session_dir() / "storage_state.json"
|
||||
|
||||
|
||||
def status_file_path() -> Path:
|
||||
return session_dir() / "status.json"
|
||||
|
||||
|
||||
def jobs_dir() -> Path:
|
||||
path = session_dir() / "jobs"
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
return path
|
||||
|
||||
|
||||
def _browser_channel() -> str:
|
||||
return os.environ.get("HUNYUAN_BROWSER_CHANNEL", "msedge").strip() or "msedge"
|
||||
|
||||
|
||||
def _write_status(**values: Any) -> dict:
|
||||
directory = session_dir()
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
payload = status()
|
||||
payload.update(values, updated_at=time.time())
|
||||
temporary = status_file_path().with_suffix(".tmp")
|
||||
temporary.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
temporary.replace(status_file_path())
|
||||
return payload
|
||||
|
||||
|
||||
def status() -> dict:
|
||||
path = status_file_path()
|
||||
if path.is_file():
|
||||
try:
|
||||
data = json.loads(path.read_text("utf-8"))
|
||||
if storage_state_path().is_file() and data.get("state") not in {
|
||||
"waiting_scan",
|
||||
"error",
|
||||
"timeout",
|
||||
}:
|
||||
data["logged_in"] = True
|
||||
return data
|
||||
except (ValueError, OSError):
|
||||
pass
|
||||
logged = storage_state_path().is_file()
|
||||
return {
|
||||
"logged_in": logged,
|
||||
"state": "ready" if logged else "logged_out",
|
||||
"message": "已登录混元3D" if logged else "尚未微信登录",
|
||||
"session_dir": str(session_dir()),
|
||||
}
|
||||
|
||||
|
||||
def is_logged_in() -> bool:
|
||||
return storage_state_path().is_file()
|
||||
|
||||
|
||||
def logout() -> dict:
|
||||
for path in (storage_state_path(), status_file_path()):
|
||||
if path.is_file():
|
||||
path.unlink()
|
||||
return _write_status(logged_in=False, state="logged_out", message="已退出混元3D 登录")
|
||||
|
||||
|
||||
def _click_first(page, labels, exact=False) -> bool:
|
||||
for label in labels:
|
||||
candidates = [
|
||||
page.get_by_role("button", name=label),
|
||||
page.get_by_role("link", name=label),
|
||||
page.get_by_text(label, exact=exact),
|
||||
]
|
||||
for locator in candidates:
|
||||
try:
|
||||
if locator.count() and locator.first.is_visible(timeout=800):
|
||||
locator.first.click(timeout=3000)
|
||||
page.wait_for_timeout(600)
|
||||
return True
|
||||
except Exception:
|
||||
continue
|
||||
return False
|
||||
|
||||
|
||||
def _looks_logged_in(page) -> bool:
|
||||
try:
|
||||
if page.get_by_text("登录", exact=True).count():
|
||||
login = page.get_by_text("登录", exact=True).first
|
||||
if login.is_visible(timeout=300):
|
||||
return False
|
||||
except Exception:
|
||||
pass
|
||||
cookies = page.context.cookies()
|
||||
interesting = [
|
||||
cookie
|
||||
for cookie in cookies
|
||||
if any(
|
||||
token in cookie["name"].lower()
|
||||
for token in ("token", "openid", "uin", "skey", "hy3d", "session", "userid")
|
||||
)
|
||||
]
|
||||
try:
|
||||
storage_keys = page.evaluate(
|
||||
"() => Object.keys({...localStorage, ...sessionStorage})"
|
||||
)
|
||||
except Exception:
|
||||
storage_keys = []
|
||||
storage_hit = any(
|
||||
any(token in key.lower() for token in ("token", "user", "auth", "openid", "uin"))
|
||||
for key in storage_keys
|
||||
)
|
||||
has_generate = False
|
||||
try:
|
||||
has_generate = bool(page.get_by_text("立即生成").count())
|
||||
except Exception:
|
||||
pass
|
||||
return bool((interesting or storage_hit) and has_generate)
|
||||
|
||||
|
||||
def start_wechat_login_blocking(
|
||||
timeout_sec: int = LOGIN_TIMEOUT_SEC,
|
||||
*,
|
||||
update: Optional[UpdateFn] = None,
|
||||
) -> dict:
|
||||
"""打开有头浏览器供微信扫码,阻塞直到成功 / 超时 / 失败。"""
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
def _emit(event: str, **payload: Any) -> None:
|
||||
if update:
|
||||
update(event=event, **payload)
|
||||
|
||||
_write_status(
|
||||
logged_in=False,
|
||||
state="waiting_scan",
|
||||
message="请在弹出的浏览器窗口中用微信扫码登录",
|
||||
)
|
||||
_emit(
|
||||
"login_waiting",
|
||||
stage="请在弹出的浏览器窗口中用微信扫码登录",
|
||||
progress=5,
|
||||
timeout_sec=timeout_sec,
|
||||
)
|
||||
|
||||
try:
|
||||
with sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch(
|
||||
channel=_browser_channel(), headless=False
|
||||
)
|
||||
context = browser.new_context(
|
||||
locale="zh-CN", viewport={"width": 1280, "height": 900}
|
||||
)
|
||||
page = context.new_page()
|
||||
page.goto(HUNYUAN_URL, wait_until="domcontentloaded", timeout=60000)
|
||||
page.wait_for_timeout(2500)
|
||||
_click_first(page, ("登录", "立即登录"), exact=True)
|
||||
_click_first(page, ("微信", "微信登录", "微信扫码"), exact=False)
|
||||
|
||||
deadline = time.monotonic() + timeout_sec
|
||||
while time.monotonic() < deadline:
|
||||
if _looks_logged_in(page):
|
||||
session_dir().mkdir(parents=True, exist_ok=True)
|
||||
context.storage_state(path=str(storage_state_path()))
|
||||
result = _write_status(
|
||||
logged_in=True, state="ready", message="微信登录成功"
|
||||
)
|
||||
browser.close()
|
||||
_emit("login_ok", stage="微信登录成功", progress=15, **result)
|
||||
return result
|
||||
page.wait_for_timeout(1500)
|
||||
|
||||
result = _write_status(
|
||||
logged_in=False, state="timeout", message="微信登录超时,请重试"
|
||||
)
|
||||
browser.close()
|
||||
_emit("login_failed", stage="微信登录超时", progress=0, **result)
|
||||
raise TimeoutError("微信登录超时,请重试")
|
||||
except TimeoutError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
result = _write_status(
|
||||
logged_in=False, state="error", message=f"登录失败:{exc}"
|
||||
)
|
||||
_emit("login_failed", stage=str(exc), progress=0, **result)
|
||||
raise RuntimeError(f"登录失败:{exc}") from exc
|
||||
|
||||
|
||||
def ensure_login(
|
||||
*,
|
||||
interactive: bool = True,
|
||||
timeout_sec: int = LOGIN_TIMEOUT_SEC,
|
||||
update: Optional[UpdateFn] = None,
|
||||
) -> dict:
|
||||
"""若无会话则弹微信登录;interactive=False 且未登录则抛错。"""
|
||||
if is_logged_in():
|
||||
current = status()
|
||||
if update:
|
||||
update(
|
||||
event="login_cached",
|
||||
stage="已有混元登录凭证",
|
||||
progress=12,
|
||||
**{k: v for k, v in current.items() if k != "event"},
|
||||
)
|
||||
return current
|
||||
|
||||
if not interactive:
|
||||
raise RuntimeError("尚未微信登录混元3D,请先执行 --login 或允许交互登录。")
|
||||
|
||||
if update:
|
||||
update(
|
||||
event="login_required",
|
||||
stage="需要微信登录混元3D",
|
||||
progress=2,
|
||||
)
|
||||
return start_wechat_login_blocking(timeout_sec=timeout_sec, update=update)
|
||||
@@ -0,0 +1,646 @@
|
||||
"""Generate and download a base mesh from the Hunyuan 3D website."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from typing import Callable, Optional
|
||||
|
||||
from .auth import HUNYUAN_URL, is_logged_in, storage_state_path
|
||||
|
||||
FACE_LABEL = "50k"
|
||||
FACE_LABELS = ("50k", "500k", "1m")
|
||||
GENERATION_TIMEOUT_SEC = 1200
|
||||
POLL_INTERVAL_SEC = 5
|
||||
API_LIST = "https://3d.hunyuan.tencent.com/api/3d/creations/list"
|
||||
API_DETAIL = "https://3d.hunyuan.tencent.com/api/3d/creations/detail"
|
||||
|
||||
UpdateFn = Callable[..., None]
|
||||
|
||||
|
||||
def _browser_channel() -> str:
|
||||
import os
|
||||
|
||||
return os.environ.get("HUNYUAN_BROWSER_CHANNEL", "msedge").strip() or "msedge"
|
||||
|
||||
|
||||
def _click_visible_text(page, labels, exact=False, timeout=5000):
|
||||
for label in labels:
|
||||
locator = page.get_by_text(label, exact=exact)
|
||||
try:
|
||||
total = locator.count()
|
||||
except Exception:
|
||||
total = 0
|
||||
for index in range(total):
|
||||
target = locator.nth(index)
|
||||
try:
|
||||
if not target.is_visible():
|
||||
continue
|
||||
target.scroll_into_view_if_needed(timeout=2000)
|
||||
target.click(timeout=timeout)
|
||||
page.wait_for_timeout(400)
|
||||
return True
|
||||
except Exception:
|
||||
continue
|
||||
return False
|
||||
|
||||
|
||||
def _dismiss_popups(page, log=None):
|
||||
"""Close common confirm/agreement toasts that block generate submit."""
|
||||
for label in ("我知道了", "知道了", "同意并继续", "同意", "确认", "继续", "关闭"):
|
||||
if _click_visible_text(page, (label,), exact=False, timeout=1200):
|
||||
if log:
|
||||
log.write(f"dismissed popup via 「{label}」\n")
|
||||
page.wait_for_timeout(400)
|
||||
|
||||
|
||||
def _click_generate(page, log=None):
|
||||
"""Click the primary studio generate button (not nav text)."""
|
||||
_dismiss_popups(page, log)
|
||||
locators = (
|
||||
page.get_by_role("button", name=re.compile(r"立即生成")),
|
||||
page.locator('button.t-button--theme-primary:has-text("立即生成")'),
|
||||
page.locator('button:has-text("立即生成")'),
|
||||
page.get_by_text("立即生成", exact=False),
|
||||
)
|
||||
for locator in locators:
|
||||
try:
|
||||
total = locator.count()
|
||||
except Exception:
|
||||
continue
|
||||
for index in range(total):
|
||||
target = locator.nth(index)
|
||||
try:
|
||||
if not target.is_visible():
|
||||
continue
|
||||
try:
|
||||
if target.is_disabled():
|
||||
continue
|
||||
except Exception:
|
||||
pass
|
||||
box = target.bounding_box()
|
||||
if box and (box["width"] < 80 or box["height"] < 28):
|
||||
continue
|
||||
target.scroll_into_view_if_needed(timeout=2000)
|
||||
target.click(timeout=5000)
|
||||
page.wait_for_timeout(600)
|
||||
if log:
|
||||
log.write("clicked generate\n")
|
||||
return True
|
||||
except Exception as exc:
|
||||
if log:
|
||||
log.write(f"generate click skipped: {exc}\n")
|
||||
continue
|
||||
for index in range(page.get_by_text("立即生成", exact=False).count()):
|
||||
target = page.get_by_text("立即生成", exact=False).nth(index)
|
||||
try:
|
||||
if not target.is_visible():
|
||||
continue
|
||||
target.click(timeout=4000, force=True)
|
||||
if log:
|
||||
log.write("clicked generate (force)\n")
|
||||
return True
|
||||
except Exception:
|
||||
continue
|
||||
return False
|
||||
|
||||
|
||||
def _wait_generations_api(generations_bodies, page, log, get_creations_id=None, timeout_sec=25):
|
||||
deadline = time.monotonic() + timeout_sec
|
||||
while time.monotonic() < deadline:
|
||||
if generations_bodies or (get_creations_id and get_creations_id()):
|
||||
return True
|
||||
_dismiss_popups(page, log)
|
||||
page.wait_for_timeout(800)
|
||||
return bool(generations_bodies or (get_creations_id and get_creations_id()))
|
||||
|
||||
|
||||
def _in_studio(page) -> bool:
|
||||
try:
|
||||
return bool(
|
||||
page.get_by_text("模型面数", exact=True).count()
|
||||
and page.get_by_text("立即生成", exact=False).count()
|
||||
)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _enter_studio(page, log):
|
||||
if _in_studio(page):
|
||||
log.write("already in studio\n")
|
||||
return
|
||||
if _click_visible_text(page, ("立即开始",), exact=False):
|
||||
page.wait_for_timeout(2500)
|
||||
if not _in_studio(page):
|
||||
_click_visible_text(page, ("图/文生3D",), exact=False)
|
||||
page.wait_for_timeout(800)
|
||||
_click_visible_text(page, ("立即开始", "开始"), exact=False)
|
||||
page.wait_for_timeout(2500)
|
||||
deadline = time.monotonic() + 30
|
||||
while time.monotonic() < deadline and not _in_studio(page):
|
||||
page.wait_for_timeout(500)
|
||||
if not _in_studio(page):
|
||||
raise RuntimeError("未能进入混元「图/文生3D」创作页,请确认登录态后重试。")
|
||||
log.write("entered studio\n")
|
||||
|
||||
|
||||
def _select_face_count(page, label=FACE_LABEL, log=None):
|
||||
aliases = {
|
||||
"50k": ("50k", "50K"),
|
||||
"500k": ("500k", "500K"),
|
||||
"1m": ("1m", "1M"),
|
||||
}
|
||||
candidates = aliases.get(label, (label,))
|
||||
for candidate in candidates:
|
||||
pattern = re.compile(rf"^\s*{re.escape(candidate)}\s*$", re.I)
|
||||
locator = page.get_by_text(pattern)
|
||||
try:
|
||||
total = locator.count()
|
||||
except Exception:
|
||||
total = 0
|
||||
for index in range(total):
|
||||
target = locator.nth(index)
|
||||
try:
|
||||
if not target.is_visible():
|
||||
continue
|
||||
box = target.bounding_box()
|
||||
if not box or box["width"] > 120 or box["height"] > 80:
|
||||
continue
|
||||
target.scroll_into_view_if_needed(timeout=2000)
|
||||
target.click(timeout=4000)
|
||||
page.wait_for_timeout(400)
|
||||
if log:
|
||||
log.write(f"selected face count {candidate}\n")
|
||||
return True
|
||||
except Exception as exc:
|
||||
if log:
|
||||
log.write(f"face click skipped: {exc}\n")
|
||||
continue
|
||||
if log:
|
||||
log.write(f"visible face chip {label} not clicked\n")
|
||||
return False
|
||||
|
||||
|
||||
def _dump_debug(page, directory: Path, log, network_hits):
|
||||
try:
|
||||
page.screenshot(path=str(directory / "hunyuan_debug.png"), full_page=True)
|
||||
log.write("saved hunyuan_debug.png\n")
|
||||
except Exception as exc:
|
||||
log.write(f"screenshot failed: {exc}\n")
|
||||
try:
|
||||
(directory / "hunyuan_debug.html").write_text(page.content(), encoding="utf-8")
|
||||
log.write("saved hunyuan_debug.html\n")
|
||||
except Exception as exc:
|
||||
log.write(f"html dump failed: {exc}\n")
|
||||
(directory / "hunyuan_network.json").write_text(
|
||||
json.dumps(network_hits[-120:], ensure_ascii=False, indent=2), encoding="utf-8"
|
||||
)
|
||||
|
||||
|
||||
def _api_json(request_context, method, url, **kwargs):
|
||||
response = getattr(request_context, method)(url, **kwargs)
|
||||
if response.status >= 400:
|
||||
if response.status in (401, 403):
|
||||
raise PermissionError(f"混元接口鉴权失败 {response.status}: {url}")
|
||||
raise RuntimeError(f"混元接口失败 {response.status}: {url}")
|
||||
try:
|
||||
return response.json()
|
||||
except Exception as exc:
|
||||
raise RuntimeError(f"混元接口返回非 JSON:{url}") from exc
|
||||
|
||||
|
||||
def _list_creations(request_context):
|
||||
return _api_json(
|
||||
request_context,
|
||||
"post",
|
||||
API_LIST,
|
||||
data=json.dumps({}),
|
||||
headers={"content-type": "application/json"},
|
||||
)
|
||||
|
||||
|
||||
def _detail_creation(request_context, creations_id):
|
||||
return _api_json(
|
||||
request_context, "get", f"{API_DETAIL}?creationsId={creations_id}"
|
||||
)
|
||||
|
||||
|
||||
def _creation_progress(creation: dict, page=None):
|
||||
"""Return (stage_text, progress_0_100, done, failed, glb_url)."""
|
||||
status = str(creation.get("status") or "").lower()
|
||||
result = (creation.get("result") or [{}])[0] if creation.get("result") else {}
|
||||
result_status = str(result.get("status") or status).lower()
|
||||
urls = result.get("urlResult") or {}
|
||||
glb = urls.get("glb") or urls.get("textureGlb")
|
||||
intermediate = result.get("intermediate_outputs") or {}
|
||||
has_geometry = bool(
|
||||
(intermediate.get("geometry") or {}).get("glb_url") or urls.get("geometryGlb")
|
||||
)
|
||||
|
||||
ui_percent = None
|
||||
ui_stage = None
|
||||
if page is not None:
|
||||
try:
|
||||
body = page.inner_text("body")
|
||||
match = re.search(
|
||||
r"(几何生成中|纹理生成中|PBR生成中|生成中)[^\d%]{0,8}(\d{1,3})\s*%", body
|
||||
)
|
||||
if match:
|
||||
ui_stage, ui_percent = match.group(1), int(match.group(2))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if result_status in {"fail", "failed", "error"} or status in {
|
||||
"fail",
|
||||
"failed",
|
||||
"error",
|
||||
}:
|
||||
return "混元生成失败", 100, False, True, None
|
||||
if result_status in {"success", "done", "finished"} and glb:
|
||||
return "混元生成完成", 95, True, False, glb
|
||||
|
||||
if ui_percent is not None:
|
||||
stage = f"资产页 · {ui_stage} {ui_percent}%"
|
||||
if ui_stage and ("纹理" in ui_stage or "PBR" in ui_stage):
|
||||
progress = 80 + min(14, ui_percent * 14 // 100)
|
||||
else:
|
||||
progress = 55 + min(24, ui_percent * 24 // 100)
|
||||
return stage, progress, False, False, None
|
||||
|
||||
if has_geometry:
|
||||
return "资产页 · 纹理/PBR 生成中", 82, False, False, None
|
||||
if status in {"wait", "waiting", "queue", "queued"}:
|
||||
return "资产页 · 排队中", 58, False, False, None
|
||||
return "资产页 · 几何生成中", 65, False, False, None
|
||||
|
||||
|
||||
def _extract_creations_id(payload):
|
||||
if payload is None:
|
||||
return None
|
||||
if isinstance(payload, dict):
|
||||
for key in ("creationsId", "creationId", "CreationsId", "id"):
|
||||
value = payload.get(key)
|
||||
if isinstance(value, str) and len(value) >= 8:
|
||||
return value
|
||||
for key in ("data", "result", "creation", "creations"):
|
||||
nested = payload.get(key)
|
||||
found = _extract_creations_id(nested)
|
||||
if found:
|
||||
return found
|
||||
if isinstance(payload.get("list"), list):
|
||||
found = _extract_creations_id(payload.get("list"))
|
||||
if found:
|
||||
return found
|
||||
if isinstance(payload, list):
|
||||
for item in payload:
|
||||
found = _extract_creations_id(item)
|
||||
if found:
|
||||
return found
|
||||
return None
|
||||
|
||||
|
||||
def _find_new_creation(listing, before_ids):
|
||||
creations = listing.get("creations") or []
|
||||
for item in creations:
|
||||
item_id = item.get("id")
|
||||
if item_id and item_id not in before_ids:
|
||||
return item
|
||||
return None
|
||||
|
||||
|
||||
def _wait_for_new_creations_id(
|
||||
request_context, before_ids, captured_id, update, log, timeout_sec=120
|
||||
):
|
||||
if captured_id and captured_id not in before_ids:
|
||||
log.write(f"creationsId from generations response: {captured_id}\n")
|
||||
return captured_id
|
||||
if captured_id and captured_id in before_ids:
|
||||
log.write(f"ignored stale creationsId from response: {captured_id}\n")
|
||||
|
||||
deadline = time.monotonic() + timeout_sec
|
||||
while time.monotonic() < deadline:
|
||||
waited = int(timeout_sec - (deadline - time.monotonic()))
|
||||
update(
|
||||
event="stage",
|
||||
stage=f"等待资产页出现新任务({waited}s / {timeout_sec}s)",
|
||||
progress=54,
|
||||
)
|
||||
listing = _list_creations(request_context)
|
||||
newest = _find_new_creation(listing, before_ids)
|
||||
if newest and newest.get("id"):
|
||||
log.write(
|
||||
f"creationsId from list delta: {newest['id']} status={newest.get('status')} "
|
||||
f"total={listing.get('totalCount')}\n"
|
||||
)
|
||||
return newest["id"]
|
||||
time.sleep(POLL_INTERVAL_SEC)
|
||||
raise RuntimeError(
|
||||
"提交后资产列表未出现新任务。可能未真正提交成功,请查看官网资产页后重试。"
|
||||
)
|
||||
|
||||
|
||||
def _download_url(request_context, url, destination: Path, log):
|
||||
response = request_context.get(url)
|
||||
if response.status >= 400:
|
||||
raise RuntimeError(f"下载模型失败 HTTP {response.status}")
|
||||
destination.write_bytes(response.body())
|
||||
log.write(f"downloaded bytes={destination.stat().st_size} -> {destination}\n")
|
||||
return destination
|
||||
|
||||
|
||||
def _extract_mesh(archive_or_file: Path, directory: Path) -> Path:
|
||||
if archive_or_file.suffix.lower() == ".glb":
|
||||
destination = directory / "generated.glb"
|
||||
if archive_or_file.resolve() != destination.resolve():
|
||||
destination.write_bytes(archive_or_file.read_bytes())
|
||||
return destination
|
||||
if archive_or_file.suffix.lower() == ".zip" or zipfile.is_zipfile(archive_or_file):
|
||||
extract_to = directory / "hunyuan_extract"
|
||||
extract_to.mkdir(exist_ok=True)
|
||||
with zipfile.ZipFile(archive_or_file, "r") as archive:
|
||||
archive.extractall(extract_to)
|
||||
glbs = sorted(extract_to.rglob("*.glb"))
|
||||
if not glbs:
|
||||
raise RuntimeError("混元下载包中未找到 GLB 模型。")
|
||||
destination = directory / "generated.glb"
|
||||
destination.write_bytes(glbs[0].read_bytes())
|
||||
return destination
|
||||
raise RuntimeError(f"不支持的混元下载格式:{archive_or_file.suffix}")
|
||||
|
||||
|
||||
def _poll_until_ready(
|
||||
request_context, page, creations_id, update, log, network_hits, directory
|
||||
):
|
||||
deadline = time.monotonic() + GENERATION_TIMEOUT_SEC
|
||||
last_stage = ""
|
||||
while time.monotonic() < deadline:
|
||||
waited = int(GENERATION_TIMEOUT_SEC - (deadline - time.monotonic()))
|
||||
try:
|
||||
detail = _detail_creation(request_context, creations_id)
|
||||
except PermissionError:
|
||||
raise
|
||||
except Exception:
|
||||
listing = _list_creations(request_context)
|
||||
detail = next(
|
||||
(
|
||||
c
|
||||
for c in (listing.get("creations") or [])
|
||||
if c.get("id") == creations_id
|
||||
),
|
||||
None,
|
||||
)
|
||||
if detail is None:
|
||||
detail = {"status": "waiting", "result": []}
|
||||
(directory / "hunyuan_creation.json").write_text(
|
||||
json.dumps(detail, ensure_ascii=False, indent=2), encoding="utf-8"
|
||||
)
|
||||
stage, progress, done, failed, glb = _creation_progress(detail, page=page)
|
||||
message = f"{stage}(已等待 {waited}s / 最长 {GENERATION_TIMEOUT_SEC}s)"
|
||||
if message != last_stage:
|
||||
log.write(message + "\n")
|
||||
last_stage = message
|
||||
update(event="stage", stage=message, progress=progress)
|
||||
if failed:
|
||||
raise RuntimeError("混元资产页显示生成失败。")
|
||||
if done and glb:
|
||||
return glb
|
||||
try:
|
||||
if page.get_by_text("几何生成中", exact=False).count() == 0:
|
||||
_click_visible_text(page, ("资产",), exact=True)
|
||||
except Exception:
|
||||
pass
|
||||
page.wait_for_timeout(POLL_INTERVAL_SEC * 1000)
|
||||
_dump_debug(page, directory, log, network_hits)
|
||||
raise TimeoutError(f"混元资产生成超时(已等待 {GENERATION_TIMEOUT_SEC}s)。")
|
||||
|
||||
|
||||
def _page_looks_logged_out(page) -> bool:
|
||||
try:
|
||||
if page.get_by_text("登录", exact=True).count():
|
||||
login = page.get_by_text("登录", exact=True).first
|
||||
if login.is_visible(timeout=500):
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
def generate_from_image(
|
||||
image_path: Path,
|
||||
directory: Path,
|
||||
update: UpdateFn,
|
||||
face_label: str = FACE_LABEL,
|
||||
) -> Path:
|
||||
"""图生3D,返回 directory/generated.glb。鉴权失败抛 PermissionError。"""
|
||||
if not is_logged_in():
|
||||
raise PermissionError("尚未微信登录混元3D,请先登录。")
|
||||
if face_label not in FACE_LABELS:
|
||||
raise ValueError(f"面数须为 {FACE_LABELS} 之一,收到: {face_label}")
|
||||
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
image_path = Path(image_path).expanduser().resolve()
|
||||
if not image_path.is_file():
|
||||
raise FileNotFoundError(f"参考图不存在: {image_path}")
|
||||
|
||||
directory = Path(directory)
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
log_path = directory / "generation.log"
|
||||
network_hits = []
|
||||
api_bodies = {}
|
||||
generations_bodies = []
|
||||
creations_id = None
|
||||
state_path = storage_state_path()
|
||||
|
||||
def on_response(response):
|
||||
nonlocal creations_id
|
||||
url = response.url
|
||||
if "/api/3d/" not in url:
|
||||
return
|
||||
entry = {"url": url, "status": response.status}
|
||||
network_hits.append(entry)
|
||||
path = url.split("?", 1)[0]
|
||||
if any(
|
||||
token in path
|
||||
for token in (
|
||||
"/creations/generations",
|
||||
"/creations/detail",
|
||||
"/creations/list",
|
||||
"/resource/download",
|
||||
)
|
||||
):
|
||||
try:
|
||||
body = response.json()
|
||||
entry["body_keys"] = (
|
||||
list(body)[:20] if isinstance(body, dict) else type(body).__name__
|
||||
)
|
||||
api_bodies.setdefault(path, body)
|
||||
if "/creations/generations" in path and isinstance(body, dict):
|
||||
generations_bodies.append(body)
|
||||
found = _extract_creations_id(body)
|
||||
if found:
|
||||
creations_id = found
|
||||
entry["creationsId"] = found
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
update(event="stage", stage="打开混元3D 网页", progress=20)
|
||||
with log_path.open("w", encoding="utf-8") as log, sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch(
|
||||
channel=_browser_channel(), headless=True
|
||||
)
|
||||
context = browser.new_context(
|
||||
storage_state=str(state_path),
|
||||
locale="zh-CN",
|
||||
accept_downloads=True,
|
||||
viewport={"width": 1600, "height": 1200},
|
||||
)
|
||||
page = context.new_page()
|
||||
page.on("response", on_response)
|
||||
try:
|
||||
page.goto(HUNYUAN_URL, wait_until="domcontentloaded", timeout=60000)
|
||||
page.wait_for_timeout(2500)
|
||||
log.write("opened hunyuan home\n")
|
||||
|
||||
if _page_looks_logged_out(page):
|
||||
raise PermissionError("混元登录凭证已失效,需要重新微信登录。")
|
||||
|
||||
update(event="stage", stage="进入图/文生3D", progress=26)
|
||||
_enter_studio(page, log)
|
||||
|
||||
before = _list_creations(context.request)
|
||||
before_ids = {
|
||||
item.get("id")
|
||||
for item in (before.get("creations") or [])
|
||||
if item.get("id")
|
||||
}
|
||||
before_count = before.get("totalCount")
|
||||
log.write(
|
||||
f"before_submit totalCount={before_count} known_ids={len(before_ids)}\n"
|
||||
)
|
||||
|
||||
update(event="stage", stage="切换到图生3D", progress=30)
|
||||
_click_visible_text(page, ("图生3D",), exact=True)
|
||||
_click_visible_text(page, ("单张图片",), exact=True)
|
||||
|
||||
update(event="stage", stage="上传参考图片", progress=36)
|
||||
file_input = page.locator('input[type="file"]').first
|
||||
if not file_input.count():
|
||||
_click_visible_text(page, ("上传图片",), exact=False)
|
||||
file_input = page.locator('input[type="file"]').first
|
||||
if not file_input.count():
|
||||
_dump_debug(page, directory, log, network_hits)
|
||||
raise RuntimeError("未找到图片上传控件,页面结构可能已变化。")
|
||||
file_input.set_input_files(str(image_path))
|
||||
page.wait_for_timeout(3500)
|
||||
log.write(f"uploaded {image_path}\n")
|
||||
upload_deadline = time.monotonic() + 30
|
||||
while time.monotonic() < upload_deadline:
|
||||
if any("/resource/review" in (e.get("url") or "") for e in network_hits):
|
||||
break
|
||||
page.wait_for_timeout(500)
|
||||
page.wait_for_timeout(1500)
|
||||
|
||||
update(event="stage", stage=f"选择 {face_label} 原始面数", progress=44)
|
||||
if not _select_face_count(page, face_label, log=log):
|
||||
_dump_debug(page, directory, log, network_hits)
|
||||
raise RuntimeError(f"未能点选模型面数 {face_label}。")
|
||||
|
||||
update(event="stage", stage="提交混元生成", progress=52)
|
||||
submitted = False
|
||||
for attempt in range(1, 4):
|
||||
if not _click_generate(page, log=log):
|
||||
_dump_debug(page, directory, log, network_hits)
|
||||
raise RuntimeError("未找到「立即生成」按钮,页面结构可能已变化。")
|
||||
log.write(f"generate attempt {attempt}/3\n")
|
||||
if _wait_generations_api(
|
||||
generations_bodies,
|
||||
page,
|
||||
log,
|
||||
get_creations_id=lambda: creations_id,
|
||||
timeout_sec=20,
|
||||
):
|
||||
submitted = True
|
||||
log.write("generations API observed\n")
|
||||
break
|
||||
log.write("no /creations/generations yet; will retry click\n")
|
||||
page.wait_for_timeout(1200)
|
||||
if not submitted:
|
||||
_dump_debug(page, directory, log, network_hits)
|
||||
raise RuntimeError(
|
||||
"点击「立即生成」后官网未发起生成请求(无 /creations/generations)。"
|
||||
"请打开混元官网确认额度/协议弹窗后重试。"
|
||||
)
|
||||
|
||||
(directory / "hunyuan_generations.json").write_text(
|
||||
json.dumps(generations_bodies, ensure_ascii=False, indent=2)[:200000],
|
||||
encoding="utf-8",
|
||||
)
|
||||
(directory / "hunyuan_api_bodies.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
k: (
|
||||
v
|
||||
if not str(k).rstrip("/").endswith("list")
|
||||
else {
|
||||
"totalCount": v.get("totalCount"),
|
||||
"first_id": (v.get("creations") or [{}])[0].get("id"),
|
||||
}
|
||||
)
|
||||
for k, v in api_bodies.items()
|
||||
},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
)[:200000],
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
creations_id = _wait_for_new_creations_id(
|
||||
context.request, before_ids, creations_id, update, log, timeout_sec=120
|
||||
)
|
||||
if creations_id in before_ids:
|
||||
raise RuntimeError(f"拒绝绑定旧资产 {creations_id},请重试提交。")
|
||||
log.write(f"bound creationsId={creations_id}\n")
|
||||
(directory / "hunyuan_binding.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"creationsId": creations_id,
|
||||
"before_count": before_count,
|
||||
"before_ids_sample": sorted(before_ids)[:5],
|
||||
},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
update(event="stage", stage="打开资产页并等待完成", progress=55)
|
||||
_click_visible_text(page, ("资产",), exact=True)
|
||||
page.wait_for_timeout(1500)
|
||||
|
||||
glb_url = _poll_until_ready(
|
||||
context.request, page, creations_id, update, log, network_hits, directory
|
||||
)
|
||||
|
||||
update(event="stage", stage="下载混元 GLB", progress=96)
|
||||
saved = directory / "hunyuan_raw.glb"
|
||||
_download_url(context.request, glb_url, saved, log)
|
||||
mesh = _extract_mesh(saved, directory)
|
||||
log.write(f"extracted {mesh}\n")
|
||||
(directory / "hunyuan_network.json").write_text(
|
||||
json.dumps(network_hits[-120:], ensure_ascii=False, indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
context.storage_state(path=str(state_path))
|
||||
return mesh
|
||||
except Exception as exc:
|
||||
log.write(f"ERROR: {exc}\n")
|
||||
try:
|
||||
_dump_debug(page, directory, log, network_hits)
|
||||
except Exception:
|
||||
pass
|
||||
raise
|
||||
finally:
|
||||
browser.close()
|
||||
@@ -0,0 +1,78 @@
|
||||
"""流程事件:stdout NDJSON / 文件 / HTTP webhook。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Union
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
||||
|
||||
|
||||
def _http_json_post(url: str, body: Dict[str, Any], timeout: float = 30.0) -> None:
|
||||
data = json.dumps(body, ensure_ascii=False).encode("utf-8")
|
||||
request = urllib.request.Request(
|
||||
url,
|
||||
data=data,
|
||||
headers={"Accept": "application/json", "Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
with urllib.request.urlopen(request, timeout=timeout) as resp:
|
||||
resp.read()
|
||||
|
||||
|
||||
class CallbackHub:
|
||||
"""向 stdout / 文件 / HTTP webhook 广播流程事件。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
callback_url: Optional[str] = None,
|
||||
callback_file: Optional[Union[str, Path]] = None,
|
||||
quiet: bool = False,
|
||||
) -> None:
|
||||
self.callback_url = callback_url
|
||||
self.callback_file = Path(callback_file) if callback_file else None
|
||||
self.quiet = quiet
|
||||
self.events: List[Dict[str, Any]] = []
|
||||
|
||||
def emit(self, event: str, **payload: Any) -> Dict[str, Any]:
|
||||
record = {
|
||||
"event": event,
|
||||
"ts": _now_iso(),
|
||||
**payload,
|
||||
}
|
||||
self.events.append(record)
|
||||
|
||||
if not self.quiet:
|
||||
print(json.dumps(record, ensure_ascii=False), flush=True)
|
||||
|
||||
if self.callback_file:
|
||||
self.callback_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
self.callback_file.write_text(
|
||||
json.dumps(
|
||||
{"latest": record, "history": self.events},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
if self.callback_url:
|
||||
try:
|
||||
_http_json_post(self.callback_url, record, timeout=30.0)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
if not self.quiet:
|
||||
warn = {
|
||||
"event": "callback_error",
|
||||
"ts": _now_iso(),
|
||||
"error": str(exc),
|
||||
"callback_url": self.callback_url,
|
||||
}
|
||||
print(json.dumps(warn, ensure_ascii=False), flush=True)
|
||||
|
||||
return record
|
||||
@@ -0,0 +1,299 @@
|
||||
#!/usr/bin/env python3
|
||||
"""腾讯混元3D 图生3D 工具。
|
||||
|
||||
支持:
|
||||
- AI / Agent CLI 调用(参数或 JSON)
|
||||
- 流程回调(HTTP webhook / 状态文件 / NDJSON 事件流)
|
||||
- 未登录时弹出 Edge 微信扫码,取得凭证后继续生成
|
||||
|
||||
依赖:playwright + 本机 Microsoft Edge。见 Tools/requirements-hunyuan.txt
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import shutil
|
||||
import sys
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional, Sequence
|
||||
|
||||
# 允许直接 `python Tools/hunyuan_3d_generate.py` 运行
|
||||
_TOOLS_DIR = Path(__file__).resolve().parent
|
||||
if str(_TOOLS_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(_TOOLS_DIR))
|
||||
|
||||
from hunyuan_3d.auth import ( # noqa: E402
|
||||
ensure_login,
|
||||
is_logged_in,
|
||||
jobs_dir,
|
||||
logout,
|
||||
start_wechat_login_blocking,
|
||||
status,
|
||||
)
|
||||
from hunyuan_3d.client import FACE_LABEL, FACE_LABELS, generate_from_image # noqa: E402
|
||||
from hunyuan_3d.events import CallbackHub # noqa: E402
|
||||
|
||||
|
||||
class GenerateError(RuntimeError):
|
||||
"""生成失败。"""
|
||||
|
||||
|
||||
def _load_json_arg(raw: Optional[str]) -> Dict[str, Any]:
|
||||
if not raw:
|
||||
return {}
|
||||
if raw == "-":
|
||||
return json.load(sys.stdin)
|
||||
path = Path(raw)
|
||||
if path.is_file():
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
return json.loads(raw)
|
||||
|
||||
|
||||
def _normalize_faces(value: Optional[str]) -> str:
|
||||
text = (value or FACE_LABEL).strip().lower()
|
||||
aliases = {
|
||||
"50k": "50k",
|
||||
"50": "50k",
|
||||
"50000": "50k",
|
||||
"500k": "500k",
|
||||
"500000": "500k",
|
||||
"1m": "1m",
|
||||
"1000000": "1m",
|
||||
"1": "1m",
|
||||
}
|
||||
label = aliases.get(text, text)
|
||||
if label not in FACE_LABELS:
|
||||
raise GenerateError(f"面数须为 {list(FACE_LABELS)} 之一,收到: {value}")
|
||||
return label
|
||||
|
||||
|
||||
def _make_update(callbacks: CallbackHub):
|
||||
def update(**payload: Any) -> None:
|
||||
event = payload.pop("event", "stage")
|
||||
callbacks.emit(event, **payload)
|
||||
|
||||
return update
|
||||
|
||||
|
||||
def run_generate(
|
||||
*,
|
||||
image: str,
|
||||
output: str,
|
||||
faces: str = FACE_LABEL,
|
||||
interactive_login: bool = True,
|
||||
login_timeout: int = 180,
|
||||
callback_url: Optional[str] = None,
|
||||
callback_file: Optional[str] = None,
|
||||
quiet: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
callbacks = CallbackHub(
|
||||
callback_url=callback_url,
|
||||
callback_file=callback_file,
|
||||
quiet=quiet,
|
||||
)
|
||||
update = _make_update(callbacks)
|
||||
|
||||
image_path = Path(image).expanduser().resolve()
|
||||
if not image_path.is_file():
|
||||
raise GenerateError(f"参考图不存在: {image_path}")
|
||||
|
||||
output_path = Path(output).expanduser().resolve()
|
||||
if output_path.suffix.lower() != ".glb":
|
||||
if output_path.is_dir() or not output_path.suffix:
|
||||
output_path = output_path / f"{image_path.stem}.glb"
|
||||
else:
|
||||
raise GenerateError("输出路径须为 .glb 文件或目录")
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
face_label = _normalize_faces(faces)
|
||||
job_id = uuid.uuid4().hex
|
||||
work_dir = jobs_dir() / job_id
|
||||
work_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
update(
|
||||
event="started",
|
||||
stage="开始混元图生3D",
|
||||
progress=1,
|
||||
job_id=job_id,
|
||||
image=str(image_path),
|
||||
output=str(output_path),
|
||||
faces=face_label,
|
||||
)
|
||||
|
||||
try:
|
||||
ensure_login(
|
||||
interactive=interactive_login,
|
||||
timeout_sec=login_timeout,
|
||||
update=update,
|
||||
)
|
||||
|
||||
attempts = 0
|
||||
while True:
|
||||
attempts += 1
|
||||
try:
|
||||
mesh = generate_from_image(
|
||||
image_path, work_dir, update, face_label=face_label
|
||||
)
|
||||
break
|
||||
except PermissionError as exc:
|
||||
if not interactive_login or attempts >= 2:
|
||||
raise GenerateError(str(exc)) from exc
|
||||
update(
|
||||
event="login_required",
|
||||
stage="凭证失效,重新微信登录",
|
||||
progress=3,
|
||||
error=str(exc),
|
||||
)
|
||||
# 清掉失效会话再扫码
|
||||
if is_logged_in():
|
||||
logout()
|
||||
start_wechat_login_blocking(
|
||||
timeout_sec=login_timeout, update=update
|
||||
)
|
||||
|
||||
shutil.copy2(mesh, output_path)
|
||||
result = {
|
||||
"ok": True,
|
||||
"job_id": job_id,
|
||||
"status": "succeeded",
|
||||
"progress": 100,
|
||||
"faces": face_label,
|
||||
"image": str(image_path),
|
||||
"output": str(output_path),
|
||||
"work_dir": str(work_dir),
|
||||
"bytes": output_path.stat().st_size,
|
||||
"hint": "生成完成后请在 Doc/ResDesc_Model.md 登记该资源",
|
||||
}
|
||||
callbacks.emit("succeeded", **result)
|
||||
return result
|
||||
except GenerateError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
err = {"ok": False, "error": str(exc), "job_id": job_id, "work_dir": str(work_dir)}
|
||||
callbacks.emit("failed", **err)
|
||||
raise GenerateError(str(exc)) from exc
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
p = argparse.ArgumentParser(
|
||||
prog="hunyuan_3d_generate",
|
||||
description="腾讯混元3D 图生3D(微信登录 + Playwright + GLB 下载)",
|
||||
)
|
||||
p.add_argument("--image", "-i", help="参考图本地路径")
|
||||
p.add_argument("--output", "-o", help="输出 GLB 路径(文件或目录)")
|
||||
p.add_argument(
|
||||
"--faces",
|
||||
default=FACE_LABEL,
|
||||
help=f"原始面数:{'/'.join(FACE_LABELS)},默认 {FACE_LABEL}",
|
||||
)
|
||||
p.add_argument(
|
||||
"--login",
|
||||
action="store_true",
|
||||
help="仅弹出微信扫码登录并保存凭证",
|
||||
)
|
||||
p.add_argument("--logout", action="store_true", help="清除本地混元登录凭证")
|
||||
p.add_argument("--status", action="store_true", help="打印当前登录状态 JSON")
|
||||
p.add_argument(
|
||||
"--no-interactive-login",
|
||||
action="store_true",
|
||||
help="未登录时不弹窗,直接失败(适合 CI)",
|
||||
)
|
||||
p.add_argument(
|
||||
"--login-timeout",
|
||||
type=int,
|
||||
default=180,
|
||||
help="微信扫码等待秒数,默认 180",
|
||||
)
|
||||
p.add_argument("--callback-url", default=None, help="流程回调 URL:每个事件 POST JSON")
|
||||
p.add_argument(
|
||||
"--callback-file",
|
||||
default=None,
|
||||
help="流程回调状态文件(持续覆盖写入 latest/history)",
|
||||
)
|
||||
p.add_argument(
|
||||
"--json",
|
||||
dest="json_input",
|
||||
default=None,
|
||||
help='JSON 参数(对象字符串 / 文件路径 / "-" 读 stdin)',
|
||||
)
|
||||
p.add_argument(
|
||||
"--result-only",
|
||||
action="store_true",
|
||||
help="只输出最终结果 JSON(抑制中间 NDJSON 事件到 stdout,仍写回调)",
|
||||
)
|
||||
p.add_argument(
|
||||
"--quiet",
|
||||
action="store_true",
|
||||
help="不向 stdout 打印事件流(最终结果仍打印)",
|
||||
)
|
||||
return p
|
||||
|
||||
|
||||
def main(argv: Optional[Sequence[str]] = None) -> int:
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(argv)
|
||||
payload = _load_json_arg(args.json_input)
|
||||
quiet_events = bool(args.quiet or args.result_only or payload.get("quiet"))
|
||||
|
||||
if args.logout or payload.get("logout"):
|
||||
print(json.dumps(logout(), ensure_ascii=False), flush=True)
|
||||
return 0
|
||||
|
||||
if args.status or payload.get("status"):
|
||||
print(json.dumps(status(), ensure_ascii=False), flush=True)
|
||||
return 0
|
||||
|
||||
if args.login or payload.get("login"):
|
||||
callbacks = CallbackHub(
|
||||
callback_url=payload.get("callback_url") or args.callback_url,
|
||||
callback_file=payload.get("callback_file") or args.callback_file,
|
||||
quiet=quiet_events,
|
||||
)
|
||||
update = _make_update(callbacks)
|
||||
try:
|
||||
result = start_wechat_login_blocking(
|
||||
timeout_sec=int(payload.get("login_timeout") or args.login_timeout),
|
||||
update=update,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
err = {"ok": False, "error": str(exc)}
|
||||
print(json.dumps(err, ensure_ascii=False), flush=True)
|
||||
return 1
|
||||
print(json.dumps({"ok": True, **result}, ensure_ascii=False), flush=True)
|
||||
return 0
|
||||
|
||||
image = payload.get("image") or args.image
|
||||
output = payload.get("output") or args.output
|
||||
if not image or not output:
|
||||
parser.error("生成模式必须提供 --image/-i 与 --output/-o(或 JSON 同名字段)")
|
||||
|
||||
try:
|
||||
result = run_generate(
|
||||
image=image,
|
||||
output=output,
|
||||
faces=payload.get("faces") or args.faces,
|
||||
interactive_login=not (
|
||||
args.no_interactive_login or payload.get("no_interactive_login")
|
||||
),
|
||||
login_timeout=int(payload.get("login_timeout") or args.login_timeout),
|
||||
callback_url=payload.get("callback_url") or args.callback_url,
|
||||
callback_file=payload.get("callback_file") or args.callback_file,
|
||||
quiet=quiet_events,
|
||||
)
|
||||
except GenerateError as exc:
|
||||
err = {"ok": False, "error": str(exc)}
|
||||
print(json.dumps(err, ensure_ascii=False), flush=True)
|
||||
return 1
|
||||
except Exception as exc: # noqa: BLE001
|
||||
err = {"ok": False, "error": str(exc)}
|
||||
print(json.dumps(err, ensure_ascii=False), flush=True)
|
||||
return 2
|
||||
|
||||
print(json.dumps(result, ensure_ascii=False), flush=True)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,48 @@
|
||||
# 高模减面与贴图烘焙
|
||||
|
||||
复刻自 `ClientTrunk/Tools/ModelTools`(`model_decimate` + `model_bake`)。
|
||||
|
||||
```text
|
||||
高模(FBX / GLB / glTF)
|
||||
-> model_decimate.py:合并 Mesh、清理、三角化、减面、重展 UV
|
||||
-> <名称>_low.fbx
|
||||
-> model_bake.py:Cycles selected-to-active 烘焙
|
||||
-> 低模 FBX + color / normal / ao / metallic / roughness PNG
|
||||
```
|
||||
|
||||
## 环境
|
||||
|
||||
- 安装 Blender(推荐 5.0),并满足其一:
|
||||
- `blender` 在 PATH
|
||||
- 环境变量 `BLENDER_EXE` 或 `BLENDER_PATH`
|
||||
- 运行时 `--blender "D:\tools\Blender\blender-5.0.0-windows-x64\blender.exe"`
|
||||
- 本机默认路径已写在 `mt_run.py`:`D:\tools\Blender\blender-5.0.0-windows-x64\blender.exe`
|
||||
- UV 观察图需要 Pillow:`pip install Pillow`
|
||||
- UVPackmaster 4 可选(已装则自动用于 UV 排布)
|
||||
|
||||
## 分步命令
|
||||
|
||||
```powershell
|
||||
python Tools/model_tools/model_decimate.py D:\Assets\house.glb --tris 5000 -o D:\Assets\house_low
|
||||
python Tools/model_tools/model_bake.py D:\Assets\house.glb D:\Assets\house_low\house_low.fbx -o D:\Assets\house_bake --size 2048
|
||||
```
|
||||
|
||||
## 一键流水线(推荐 Agent 使用)
|
||||
|
||||
```powershell
|
||||
python Tools/model_tools/model_lowpoly_bake.py D:\Assets\house.glb -o Tools/output/house --tris 5000 --size 2048
|
||||
```
|
||||
|
||||
输出示例:
|
||||
|
||||
```text
|
||||
Tools/output/house/house/
|
||||
house.fbx
|
||||
house_color.png
|
||||
house_normal.png
|
||||
house_ao.png
|
||||
house_metallic.png
|
||||
house_roughness.png
|
||||
```
|
||||
|
||||
生成完成后请在 `Doc/ResDesc_Model.md` 登记资源。
|
||||
@@ -0,0 +1,216 @@
|
||||
"""Blender 内运行:高低模 Cycles 烘焙,低模 UV 上出 normal/ao/color/metallic/roughness。
|
||||
调用:blender -b --factory-startup --python bl_bake.py -- \
|
||||
<high.fbx|high.glb|high.gltf> <low.fbx> <outdir> <size> <ray_distance|auto> <samples>
|
||||
"""
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
DEFAULT_RAY_PCT = 0.02 # 自动射线距离 = 低模包围盒对角线 * 2%
|
||||
BBOX_TOL = 0.10 # 高低模包围盒尺寸相对差警告阈值
|
||||
|
||||
# (输出名, bake type, EMIT 来源的 Principled 输入, 目标图颜色空间)
|
||||
BAKE_PASSES = [
|
||||
("normal", 'NORMAL', None, 'Non-Color'),
|
||||
("ao", 'AO', None, 'Non-Color'),
|
||||
("color", 'EMIT', 'Base Color', 'sRGB'),
|
||||
("metallic", 'EMIT', 'Metallic', 'Non-Color'),
|
||||
("roughness", 'EMIT', 'Roughness', 'Non-Color'),
|
||||
]
|
||||
|
||||
|
||||
def estimate_ray_distance(bbox_dims, pct=DEFAULT_RAY_PCT):
|
||||
"""低模包围盒尺寸 (dx,dy,dz) -> 射线距离 = 对角线长 * pct。"""
|
||||
return math.sqrt(sum(d * d for d in bbox_dims)) * pct
|
||||
|
||||
|
||||
def output_stem(low_name):
|
||||
"""低模名去掉 _low 后缀作为输出前缀(对齐工具3纯网格贴图命名约定)。"""
|
||||
return low_name[:-4] if low_name.endswith("_low") else low_name
|
||||
|
||||
|
||||
def bbox_mismatch(high_dims, low_dims, tol=BBOX_TOL):
|
||||
"""任一轴尺寸相对差超 tol 返回 True;接近 0 的轴忽略(平面模型)。"""
|
||||
for h, l in zip(high_dims, low_dims):
|
||||
m = max(abs(h), abs(l))
|
||||
if m > 1e-9 and abs(h - l) / m > tol:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
# ---------------- 以下仅在 Blender 内执行 ----------------
|
||||
|
||||
|
||||
def _import_model_joined(path, warnings):
|
||||
"""导入 FBX/GLB/glTF 并把其中所有 mesh join 成单对象;无 mesh 返回 None。"""
|
||||
import bpy
|
||||
from bl_decimate import import_model_objects, join_meshes
|
||||
new = import_model_objects(path)
|
||||
meshes = [o for o in new if o.type == 'MESH']
|
||||
skipped = [o.name for o in new if o.type != 'MESH']
|
||||
if skipped:
|
||||
warnings.append("%s:忽略非 mesh 对象 %s"
|
||||
% (os.path.basename(path), ", ".join(skipped)))
|
||||
if not meshes:
|
||||
return None
|
||||
obj = join_meshes(meshes)
|
||||
# 应用物体变换(世界坐标不变):cage_extrusion/max_ray_distance 按低模局部空间解释,
|
||||
# 未应用的缩放(如 cm 单位 FBX 的 scale=0.01)会把射线距离缩到近零导致大面积打空(脏色)
|
||||
bpy.ops.object.select_all(action='DESELECT')
|
||||
obj.select_set(True)
|
||||
bpy.context.view_layer.objects.active = obj
|
||||
bpy.ops.object.transform_apply(location=True, rotation=True, scale=True)
|
||||
return obj
|
||||
|
||||
|
||||
def _setup_bake_target(low, stem):
|
||||
"""低模换成单一烘焙材质,返回接收烘焙结果的 image 节点。"""
|
||||
import bpy
|
||||
mat = bpy.data.materials.new(stem)
|
||||
mat.use_nodes = True
|
||||
node = mat.node_tree.nodes.new('ShaderNodeTexImage')
|
||||
mat.node_tree.nodes.active = node
|
||||
low.data.materials.clear()
|
||||
low.data.materials.append(mat)
|
||||
return mat, node
|
||||
|
||||
|
||||
def _high_materials(high):
|
||||
return [s.material for s in high.material_slots if s.material]
|
||||
|
||||
|
||||
def _rewire_emit(mat, input_name, warnings):
|
||||
"""把 Principled 指定输入(连线或常量)改接 Emission 直连输出,供 EMIT 烘焙。"""
|
||||
import bpy
|
||||
if not mat.use_nodes:
|
||||
mat.use_nodes = True
|
||||
nt = mat.node_tree
|
||||
bsdf = next((n for n in nt.nodes if n.type == 'BSDF_PRINCIPLED'), None)
|
||||
out = next((n for n in nt.nodes if n.type == 'OUTPUT_MATERIAL'), None)
|
||||
if out is None:
|
||||
out = nt.nodes.new('ShaderNodeOutputMaterial')
|
||||
emit = nt.nodes.get("mt_emit")
|
||||
if emit is None:
|
||||
emit = nt.nodes.new('ShaderNodeEmission')
|
||||
emit.name = "mt_emit"
|
||||
for l in list(emit.inputs['Color'].links):
|
||||
nt.links.remove(l)
|
||||
if bsdf is None:
|
||||
warnings.append("材质 %s 无 Principled BSDF,%s 用 0.5 灰常量"
|
||||
% (mat.name, input_name))
|
||||
emit.inputs['Color'].default_value = (0.5, 0.5, 0.5, 1.0)
|
||||
else:
|
||||
sock = bsdf.inputs[input_name]
|
||||
if sock.is_linked:
|
||||
nt.links.new(sock.links[0].from_socket, emit.inputs['Color'])
|
||||
else:
|
||||
v = sock.default_value
|
||||
if isinstance(v, float):
|
||||
# 该通道无贴图,把常量值直接烘入
|
||||
warnings.append("%s.%s 无贴图,(常量) %.3f 烘入" % (mat.name, input_name, v))
|
||||
emit.inputs['Color'].default_value = (v, v, v, 1.0)
|
||||
else: # Base Color 是 4 分量
|
||||
# 该通道无贴图,把常量色直接烘入
|
||||
warnings.append("%s.%s 无贴图,(常量色 %.3f/%.3f/%.3f) 烘入"
|
||||
% (mat.name, input_name, v[0], v[1], v[2]))
|
||||
emit.inputs['Color'].default_value = (v[0], v[1], v[2], 1.0)
|
||||
for l in list(out.inputs['Surface'].links):
|
||||
nt.links.remove(l)
|
||||
nt.links.new(emit.outputs['Emission'], out.inputs['Surface'])
|
||||
|
||||
|
||||
def _bake_pass(low, high, target_node, name, bake_type, size, colorspace,
|
||||
ray, samples, outdir, stem):
|
||||
"""执行一个烘焙 pass 并保存 PNG,返回文件名。"""
|
||||
import bpy
|
||||
img = bpy.data.images.new("mt_bake_" + name, size, size, alpha=False)
|
||||
img.colorspace_settings.name = colorspace
|
||||
target_node.image = img
|
||||
|
||||
bpy.ops.object.select_all(action='DESELECT')
|
||||
high.select_set(True)
|
||||
low.select_set(True)
|
||||
bpy.context.view_layer.objects.active = low
|
||||
bpy.context.scene.cycles.samples = samples
|
||||
|
||||
kwargs = dict(type=bake_type, use_selected_to_active=True,
|
||||
cage_extrusion=ray, max_ray_distance=ray * 2.0,
|
||||
margin=16, use_clear=True)
|
||||
if bake_type == 'NORMAL':
|
||||
kwargs["normal_space"] = 'TANGENT' # OpenGL +Y
|
||||
bpy.ops.object.bake(**kwargs)
|
||||
|
||||
path = os.path.abspath(os.path.join(outdir, "%s_%s.png" % (stem, name)))
|
||||
img.filepath_raw = path
|
||||
img.file_format = 'PNG'
|
||||
img.save()
|
||||
bpy.data.images.remove(img)
|
||||
return os.path.basename(path)
|
||||
|
||||
|
||||
def main():
|
||||
import bpy
|
||||
import json
|
||||
argv = sys.argv[sys.argv.index("--") + 1:]
|
||||
high_model, low_fbx, outdir = argv[0], argv[1], argv[2]
|
||||
size, ray_arg, ao_samples = int(argv[3]), argv[4], int(argv[5])
|
||||
warnings = []
|
||||
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
high = _import_model_joined(high_model, warnings)
|
||||
low = _import_model_joined(low_fbx, warnings)
|
||||
if high is None or low is None:
|
||||
print("MT_SUMMARY " + json.dumps(
|
||||
{"error": "高模或低模中没有 mesh"}, ensure_ascii=False))
|
||||
return
|
||||
if not low.data.uv_layers:
|
||||
print("MT_SUMMARY " + json.dumps(
|
||||
{"error": "低模没有 UV 层,请先用 model_decimate.py 生成"},
|
||||
ensure_ascii=False))
|
||||
return
|
||||
|
||||
high_dims, low_dims = tuple(high.dimensions), tuple(low.dimensions)
|
||||
if bbox_mismatch(high_dims, low_dims):
|
||||
warnings.append("高低模包围盒尺寸差异超 %d%%:高 %s 低 %s,确认是同一模型且坐标已对齐"
|
||||
% (BBOX_TOL * 100,
|
||||
[round(d, 3) for d in high_dims],
|
||||
[round(d, 3) for d in low_dims]))
|
||||
ray = estimate_ray_distance(low_dims) if ray_arg == "auto" else float(ray_arg)
|
||||
|
||||
stem = output_stem(os.path.splitext(os.path.basename(low_fbx))[0])
|
||||
os.makedirs(outdir, exist_ok=True)
|
||||
|
||||
scene = bpy.context.scene
|
||||
scene.render.engine = 'CYCLES'
|
||||
scene.cycles.device = 'CPU'
|
||||
bake_mat, target_node = _setup_bake_target(low, stem)
|
||||
|
||||
mats = _high_materials(high)
|
||||
outputs = {}
|
||||
for name, bake_type, emit_input, colorspace in BAKE_PASSES:
|
||||
if emit_input is not None: # EMIT trick:先改写全部高模材质
|
||||
for m in mats:
|
||||
_rewire_emit(m, emit_input, warnings)
|
||||
samples = ao_samples if bake_type == 'AO' else 1
|
||||
outputs[name] = _bake_pass(low, high, target_node, name, bake_type,
|
||||
size, colorspace, ray, samples, outdir, stem)
|
||||
|
||||
# 导出低模:剥掉烘焙 image 节点,材质名保留 stem(工具3 兜底识别用)
|
||||
bake_mat.node_tree.nodes.remove(target_node)
|
||||
out_fbx = os.path.join(outdir, stem + ".fbx")
|
||||
bpy.ops.object.select_all(action='DESELECT')
|
||||
low.select_set(True)
|
||||
bpy.ops.export_scene.fbx(filepath=out_fbx, use_selection=True,
|
||||
path_mode='STRIP', embed_textures=False)
|
||||
|
||||
print("MT_SUMMARY " + json.dumps(
|
||||
{"stem": stem, "fbx": os.path.basename(out_fbx), "outputs": outputs,
|
||||
"size": size, "ray_distance": round(ray, 6),
|
||||
"high_tris": len(high.data.polygons), "low_tris": len(low.data.polygons),
|
||||
"warnings": warnings}, ensure_ascii=False))
|
||||
|
||||
|
||||
if __name__ == "__main__" and "--" in sys.argv:
|
||||
main()
|
||||
@@ -0,0 +1,481 @@
|
||||
"""Blender 内运行:FBX/GLB/glTF 减面到指定三角面数 + UV 重展(锐边 seam 或 Smart UV Project)。
|
||||
调用:blender -b --factory-startup --python bl_decimate.py -- <src> <out.fbx> <target_tris> [seam|smart]
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
SUPPORTED_MODEL_EXTENSIONS = (".fbx", ".glb", ".gltf")
|
||||
|
||||
|
||||
def model_format(path):
|
||||
"""Return the Blender importer key for a supported model path."""
|
||||
ext = os.path.splitext(path)[1].lower()
|
||||
if ext == ".fbx":
|
||||
return "fbx"
|
||||
if ext in (".glb", ".gltf"):
|
||||
return "gltf"
|
||||
raise ValueError(
|
||||
"unsupported model format %r (supported: %s)"
|
||||
% (ext or "<none>", ", ".join(SUPPORTED_MODEL_EXTENSIONS)))
|
||||
|
||||
|
||||
def import_model_objects(path):
|
||||
"""Import FBX/GLB/glTF and return only objects created by the importer."""
|
||||
import bpy
|
||||
before = set(bpy.data.objects)
|
||||
fmt = model_format(path)
|
||||
if fmt == "fbx":
|
||||
bpy.ops.import_scene.fbx(filepath=path)
|
||||
else:
|
||||
bpy.ops.import_scene.gltf(filepath=path)
|
||||
return [obj for obj in bpy.data.objects if obj not in before]
|
||||
|
||||
TOLERANCE = 0.03 # 面数相对误差容忍
|
||||
MAX_RETRY = 2 # ratio 修正轮数上限
|
||||
|
||||
|
||||
def decimate_ratio(target, cur):
|
||||
"""Decimate collapse ratio;cur <= target 或非法时返回 1.0(不减面)。"""
|
||||
if cur <= 0 or cur <= target:
|
||||
return 1.0
|
||||
return target / float(cur)
|
||||
|
||||
|
||||
def within_tolerance(target, actual, tol=TOLERANCE):
|
||||
"""减面结果是否落在目标 ±tol 内。"""
|
||||
if target <= 0:
|
||||
return False
|
||||
return abs(actual - target) <= target * tol
|
||||
|
||||
|
||||
UV_FLIP_TOL = 0.02 # 质量门:UV 翻转面占比超此值回退 smart_project
|
||||
UV_OVERLAP_TOL = 0.08 # 质量门:UV 重叠面占比阈值(手工 UV 同口径约 5%,灾难性失败 >20%)
|
||||
|
||||
|
||||
def uv_gate_ok(flipped, overlap, overlap_tol=UV_OVERLAP_TOL):
|
||||
"""UV 质量门:翻转按 UV_FLIP_TOL 固定,重叠按 overlap_tol(默认 UV_OVERLAP_TOL)。
|
||||
overlap 为 None(op 不可用)按 0 处理。"""
|
||||
return flipped <= UV_FLIP_TOL and (overlap or 0.0) <= overlap_tol
|
||||
|
||||
|
||||
def uvpm_mode_label(base, applied):
|
||||
"""UV 模式标注:UVPackmaster 排布生效时加 +uvpm 后缀。"""
|
||||
return base + "+uvpm" if applied else base
|
||||
|
||||
|
||||
def pick_best_candidate(candidates, overlap_tol=UV_OVERLAP_TOL):
|
||||
"""从候选 UV 指标 dict 列表选过质量门且岛数最少者;无过门候选返回 None。
|
||||
overlap_tol 覆盖重叠容忍。每个 candidate 至少含 flipped/overlap/islands。"""
|
||||
passing = [c for c in candidates
|
||||
if uv_gate_ok(c["flipped"], c["overlap"], overlap_tol)]
|
||||
if not passing:
|
||||
return None
|
||||
return min(passing, key=lambda c: c["islands"])
|
||||
|
||||
|
||||
def count_islands(n_faces, adjacent_pairs):
|
||||
"""union-find 数 UV 岛:n_faces 个面,adjacent_pairs 为 UV 连续的面索引对。"""
|
||||
parent = list(range(n_faces))
|
||||
|
||||
def find(i):
|
||||
while parent[i] != i:
|
||||
parent[i] = parent[parent[i]]
|
||||
i = parent[i]
|
||||
return i
|
||||
|
||||
for a, b in adjacent_pairs:
|
||||
ra, rb = find(a), find(b)
|
||||
if ra != rb:
|
||||
parent[ra] = rb
|
||||
return len({find(i) for i in range(n_faces)})
|
||||
|
||||
|
||||
def flipped_fraction(signed_areas):
|
||||
"""UV 面签名面积为负(翻转)的占比。"""
|
||||
if not signed_areas:
|
||||
return 0.0
|
||||
return sum(1 for a in signed_areas if a < 0) / float(len(signed_areas))
|
||||
|
||||
|
||||
def fill_ratio(abs_areas):
|
||||
"""UV 空间利用率:面积和(重叠会虚高,截断到 1.0)。"""
|
||||
return min(1.0, sum(abs_areas))
|
||||
|
||||
|
||||
# ---------------- 以下仅在 Blender 内执行 ----------------
|
||||
|
||||
|
||||
def join_meshes(objs):
|
||||
"""多 mesh join 成单对象,返回结果对象(bl_bake 复用)。"""
|
||||
import bpy
|
||||
bpy.ops.object.select_all(action='DESELECT')
|
||||
for o in objs:
|
||||
o.select_set(True)
|
||||
bpy.context.view_layer.objects.active = objs[0]
|
||||
if len(objs) > 1:
|
||||
bpy.ops.object.join()
|
||||
return bpy.context.view_layer.objects.active
|
||||
|
||||
|
||||
def _apply_modifier(obj, mod):
|
||||
import bpy
|
||||
bpy.context.view_layer.objects.active = obj
|
||||
bpy.ops.object.modifier_apply(modifier=mod.name)
|
||||
|
||||
|
||||
def _triangulate(obj):
|
||||
mod = obj.modifiers.new("mt_tri", 'TRIANGULATE')
|
||||
_apply_modifier(obj, mod)
|
||||
|
||||
|
||||
def _decimate(obj, ratio):
|
||||
mod = obj.modifiers.new("mt_dec", 'DECIMATE')
|
||||
mod.decimate_type = 'COLLAPSE'
|
||||
mod.ratio = ratio
|
||||
mod.use_collapse_triangulate = True
|
||||
_apply_modifier(obj, mod)
|
||||
|
||||
|
||||
def _clean_mesh(obj, warnings):
|
||||
"""减面前清理:按局部包围盒对角线相对焊接重合点 + 去退化面,给 collapse 更干净的流形,
|
||||
减少被迫加的 seam。各步失败降级不中断。"""
|
||||
import bpy
|
||||
import mathutils
|
||||
bb = [mathutils.Vector(c) for c in obj.bound_box]
|
||||
diag = (bb[0] - bb[6]).length
|
||||
bpy.context.view_layer.objects.active = obj
|
||||
bpy.ops.object.mode_set(mode='EDIT')
|
||||
bpy.ops.mesh.select_all(action='SELECT')
|
||||
if diag > 0.0:
|
||||
try:
|
||||
bpy.ops.mesh.remove_doubles(threshold=diag * REL_WELD)
|
||||
except RuntimeError as e:
|
||||
warnings.append("remove_doubles 失败(%s),跳过焊接" % e)
|
||||
else:
|
||||
warnings.append("包围盒对角线为 0,跳过焊接")
|
||||
try:
|
||||
bpy.ops.mesh.dissolve_degenerate()
|
||||
except RuntimeError as e:
|
||||
warnings.append("dissolve_degenerate 失败(%s),跳过去退化" % e)
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
|
||||
|
||||
SEAM_ANGLE_DEG = 66.0 # 锐边阈值:两面夹角超此值标 seam
|
||||
PACK_MARGIN = 0.002 # 岛间距 ≈ 2048 图 4px
|
||||
UVPM_EXT = "bl_ext.user_default.uvpackmaster4" # UVPackmaster 4 扩展模块名
|
||||
UVPM_PIXEL_MARGIN = 4 # UVPM 岛间距(像素),与 PACK_MARGIN * UVPM_TEX_SIZE 同口径
|
||||
UVPM_TEX_SIZE = 2048
|
||||
UVPM_ROTATION_STEP = 15 # UVPM 旋转步进(度);比默认 90 更细,利于薄斜条对齐(启发式因引擎崩溃已弃用)
|
||||
SEAM_ANGLE_SWEEP = (55.0, 45.0, 35.0) # 首选 SEAM_ANGLE_DEG 不过门时依次下探的 seam 角度
|
||||
STRETCH_ITERS = 30 # minimize_stretch 松弛迭代次数
|
||||
REL_WELD = 1e-4 # 焊接距离占局部包围盒对角线比例(避免 cm/m 单位差异导致绝对阈值失准)
|
||||
|
||||
|
||||
def _clear_uv_layers(mesh):
|
||||
while mesh.uv_layers:
|
||||
mesh.uv_layers.remove(mesh.uv_layers[0])
|
||||
|
||||
|
||||
def _unwrap_smart(obj):
|
||||
"""Smart UV Project(原路径,也是质量门的回退路径)。"""
|
||||
import bpy
|
||||
import math
|
||||
_clear_uv_layers(obj.data)
|
||||
bpy.context.view_layer.objects.active = obj
|
||||
bpy.ops.object.mode_set(mode='EDIT')
|
||||
bpy.ops.mesh.select_all(action='SELECT')
|
||||
bpy.ops.uv.smart_project(angle_limit=math.radians(66.0), island_margin=PACK_MARGIN)
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
|
||||
|
||||
def _unwrap_seam(obj, warnings, seam_angle_deg=SEAM_ANGLE_DEG):
|
||||
"""锐边标 seam -> MINIMUM_STRETCH/ANGLE_BASED 展开 -> 松弛 -> 纹素均衡 -> pack。
|
||||
seam_angle_deg 可变以支持角度扫描;重跑前清掉上一档残留 seam。"""
|
||||
import bpy
|
||||
import math
|
||||
_clear_uv_layers(obj.data)
|
||||
obj.data.uv_layers.new()
|
||||
bpy.context.view_layer.objects.active = obj
|
||||
bpy.ops.object.mode_set(mode='EDIT')
|
||||
bpy.ops.mesh.select_mode(type='EDGE')
|
||||
# 清掉上一档角度残留 seam(同一对象多角度扫描时必须;同时丢弃源资产自带 seam,保证确定性)
|
||||
bpy.ops.mesh.select_all(action='SELECT')
|
||||
bpy.ops.mesh.mark_seam(clear=True)
|
||||
# 锐边标 seam:接缝只落在硬边处,平滑区域保持整岛
|
||||
bpy.ops.mesh.select_all(action='DESELECT')
|
||||
bpy.ops.mesh.edges_select_sharp(sharpness=math.radians(seam_angle_deg))
|
||||
bpy.ops.mesh.mark_seam(clear=False)
|
||||
bpy.ops.mesh.select_all(action='SELECT')
|
||||
try:
|
||||
bpy.ops.uv.unwrap(method='MINIMUM_STRETCH', margin=PACK_MARGIN)
|
||||
except TypeError: # 老版本无 SLIM 枚举
|
||||
warnings.append("MINIMUM_STRETCH 不可用,展开改用 ANGLE_BASED")
|
||||
bpy.ops.uv.unwrap(method='ANGLE_BASED', margin=PACK_MARGIN)
|
||||
try: # 角度松弛:压翻转/拉伸
|
||||
bpy.ops.uv.minimize_stretch(iterations=STRETCH_ITERS)
|
||||
except (RuntimeError, TypeError) as e:
|
||||
warnings.append("minimize_stretch 不可用(%s),跳过松弛" % e)
|
||||
try: # 统一纹素密度,避免大岛霸占分辨率
|
||||
bpy.ops.uv.average_islands_scale()
|
||||
except (RuntimeError, TypeError) as e:
|
||||
warnings.append("average_islands_scale 不可用(%s),跳过纹素均衡" % e)
|
||||
try:
|
||||
bpy.ops.uv.pack_islands(rotate=True, margin=PACK_MARGIN)
|
||||
except RuntimeError: # headless 上下文不满足时靠 unwrap 自带打包
|
||||
warnings.append("pack_islands 不可用,沿用 unwrap 自带布局")
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
|
||||
|
||||
def _collect_uv_metrics(obj):
|
||||
"""bmesh 提取 UV 指标:岛数(UV 连续边并查集)、利用率、翻转占比、重叠占比。"""
|
||||
import bmesh
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(obj.data)
|
||||
uv = bm.loops.layers.uv.active
|
||||
pairs = []
|
||||
for e in bm.edges:
|
||||
if len(e.link_faces) != 2:
|
||||
continue
|
||||
f1, f2 = e.link_faces
|
||||
cont = True
|
||||
for v in e.verts: # 两面在该边两端 UV 一致 => UV 连续(同岛)
|
||||
l1 = next(l for l in f1.loops if l.vert == v)
|
||||
l2 = next(l for l in f2.loops if l.vert == v)
|
||||
if (l1[uv].uv - l2[uv].uv).length > 1e-6:
|
||||
cont = False
|
||||
break
|
||||
if cont:
|
||||
pairs.append((f1.index, f2.index))
|
||||
signed = []
|
||||
for f in bm.faces:
|
||||
area = 0.0
|
||||
loops = f.loops
|
||||
for i in range(len(loops)):
|
||||
a = loops[i][uv].uv
|
||||
b = loops[(i + 1) % len(loops)][uv].uv
|
||||
area += a.x * b.y - b.x * a.y
|
||||
signed.append(area * 0.5)
|
||||
n = len(bm.faces)
|
||||
bm.free()
|
||||
return {"islands": count_islands(n, pairs),
|
||||
"fill": round(fill_ratio([abs(a) for a in signed]), 4),
|
||||
"flipped": round(flipped_fraction(signed), 4),
|
||||
"overlap": _overlap_fraction(obj)}
|
||||
|
||||
|
||||
def _overlap_fraction(obj):
|
||||
"""uv.select_overlap 统计重叠 UV 面占比;op 上下文不可用返回 None。"""
|
||||
import bpy
|
||||
bpy.context.scene.tool_settings.use_uv_select_sync = True
|
||||
bpy.context.view_layer.objects.active = obj
|
||||
bpy.ops.object.mode_set(mode='EDIT')
|
||||
bpy.ops.mesh.select_all(action='DESELECT')
|
||||
try:
|
||||
bpy.ops.uv.select_overlap()
|
||||
except RuntimeError:
|
||||
return None
|
||||
finally:
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
polys = obj.data.polygons
|
||||
if not len(polys):
|
||||
return 0.0
|
||||
return round(sum(1 for p in polys if p.select) / float(len(polys)), 4)
|
||||
|
||||
|
||||
_uvpm_failed = False # 进程内失败一次即不再重试(避免重复警告与启动开销)
|
||||
|
||||
|
||||
def _uvpm_enable():
|
||||
"""启用 UVPM4 扩展(幂等)。headless 下先补丁 GPU shader 创建——
|
||||
UVPM4 导入期为视口覆盖层建 shader,后台模式无 GPU 绘图会 SystemError;
|
||||
覆盖层仅交互用,pack 不受影响。引擎路径经注册表自动发现。"""
|
||||
import bpy
|
||||
import addon_utils
|
||||
if bpy.app.background:
|
||||
import gpu
|
||||
orig = gpu.shader.from_builtin
|
||||
if not getattr(orig, "_mt_safe", False):
|
||||
def _safe(*a, **k):
|
||||
try:
|
||||
return orig(*a, **k)
|
||||
except SystemError:
|
||||
return None
|
||||
_safe._mt_safe = True
|
||||
gpu.shader.from_builtin = _safe
|
||||
# default_set=True 才建 preferences.addons 条目(UVPM4 register 依赖);
|
||||
# factory-startup 关闭偏好自动保存,不会写盘
|
||||
if addon_utils.enable(UVPM_EXT, default_set=True) is None:
|
||||
raise RuntimeError("扩展 %s 启用失败(未安装或版本不兼容)" % UVPM_EXT)
|
||||
|
||||
|
||||
def _uvpm_set(p, name, value, warnings):
|
||||
"""防御式设 UVPM 属性:属性存在才设,否则记警告跳过——
|
||||
名字不匹配(UVPM 版本差异)时降级为按原 margin 跑 UVPM,不丢整个排布。"""
|
||||
if hasattr(p, name):
|
||||
try:
|
||||
setattr(p, name, value)
|
||||
except Exception as e:
|
||||
warnings.append("UVPM 属性 %s 设置失败(%s),跳过" % (name, e))
|
||||
else:
|
||||
warnings.append("UVPM 属性 %s 不存在,跳过" % name)
|
||||
|
||||
|
||||
def _uvpm_repack(obj, warnings):
|
||||
"""UVPM4 重排当前 UV 布局,成功返回 True;任何失败记警告返回 False,
|
||||
保底布局(pack_islands/smart_project)原样保留。rotation_step 调细以利薄斜条对齐。
|
||||
注:UVPM 启发式搜索(heuristic)在 headless 下会随机崩溃引擎进程(Engine process died),
|
||||
确定性/稳定性差,故不启用——只用旋转增强这一稳定杠杆。"""
|
||||
global _uvpm_failed
|
||||
if _uvpm_failed:
|
||||
return False
|
||||
import bpy
|
||||
try:
|
||||
_uvpm_enable()
|
||||
p = bpy.context.scene.uvpm4_props.default_main_props
|
||||
p.pixel_margin_enable = True
|
||||
p.pixel_margin = UVPM_PIXEL_MARGIN
|
||||
p.pixel_margin_tex_size = UVPM_TEX_SIZE
|
||||
_uvpm_set(p, "rotation_enable", True, warnings)
|
||||
_uvpm_set(p, "rotation_step", UVPM_ROTATION_STEP, warnings)
|
||||
bpy.context.scene.tool_settings.use_uv_select_sync = True
|
||||
bpy.context.view_layer.objects.active = obj
|
||||
bpy.ops.object.mode_set(mode='EDIT')
|
||||
bpy.ops.mesh.select_all(action='SELECT')
|
||||
try:
|
||||
ret = bpy.ops.uvpackmaster4.pack(mode_id='pack.single_tile',
|
||||
pack_op_type='0')
|
||||
finally:
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
if 'FINISHED' not in ret:
|
||||
raise RuntimeError("pack 返回 %s" % sorted(ret))
|
||||
return True
|
||||
except Exception as e:
|
||||
_uvpm_failed = True
|
||||
warnings.append("UVPackmaster 排布不可用(%s),沿用内置 pack 布局" % e)
|
||||
return False
|
||||
|
||||
|
||||
def _do_unwrap(obj, mode, warnings, overlap_tol=UV_OVERLAP_TOL):
|
||||
"""按模式展开:seam 从 SEAM_ANGLE_DEG 起降序扫 SEAM_ANGLE_SWEEP,首个过门者即选并停扫
|
||||
——岛数随角度降单调增,故首个过门者已是过门候选里岛数最少的(pick_best_candidate 据此在
|
||||
候选集取岛数最少者,与早停一致;重跑分支为防御:早停下 best 恒为最后一档,通常不触发);
|
||||
全失败退 smart。overlap_tol 覆盖重叠门限(翻转仍严格)。排布 UVPM4 增强,只在最终 UV
|
||||
上跑一次,失败保底内置 pack。返回 uv 指标 dict(含 mode)。"""
|
||||
if mode == "seam":
|
||||
angles = [SEAM_ANGLE_DEG] + list(SEAM_ANGLE_SWEEP)
|
||||
candidates = []
|
||||
for ang in angles:
|
||||
_unwrap_seam(obj, warnings, seam_angle_deg=ang)
|
||||
m = _collect_uv_metrics(obj)
|
||||
m["angle"] = ang
|
||||
candidates.append(m)
|
||||
if uv_gate_ok(m["flipped"], m["overlap"], overlap_tol):
|
||||
break # 该档已过门;更低角度只会更碎,无需再试
|
||||
best = pick_best_candidate(candidates, overlap_tol)
|
||||
if best is not None:
|
||||
if best["angle"] != candidates[-1]["angle"]:
|
||||
# 选中档不是最后跑的那档,重跑恢复其 UV(_unwrap_seam 会覆盖)
|
||||
_unwrap_seam(obj, warnings, seam_angle_deg=best["angle"])
|
||||
uvpm = _uvpm_repack(obj, warnings)
|
||||
m = _collect_uv_metrics(obj)
|
||||
m["mode"] = uvpm_mode_label("seam@%d" % int(best["angle"]), uvpm)
|
||||
return m
|
||||
detail = "、".join(
|
||||
"%d°(翻转%.1f%% 重叠%s)" % (
|
||||
int(c["angle"]), c["flipped"] * 100,
|
||||
"%.1f%%" % (c["overlap"] * 100) if c["overlap"] is not None else "未知")
|
||||
for c in candidates)
|
||||
warnings.append("所有 seam 角度均未过质量门(%s),回退 smart_project" % detail)
|
||||
mode = "smart_fallback"
|
||||
_unwrap_smart(obj)
|
||||
uvpm = _uvpm_repack(obj, warnings)
|
||||
m = _collect_uv_metrics(obj)
|
||||
m["mode"] = uvpm_mode_label(mode if mode == "smart_fallback" else "smart", uvpm)
|
||||
return m
|
||||
|
||||
|
||||
def _collect_uv_polygons(obj):
|
||||
"""提取每个面的 UV 多边形(归一化坐标):[[[u,v], ...], ...],供外部 PIL 绘图。
|
||||
无 UV 层返回空列表。"""
|
||||
import bmesh
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(obj.data)
|
||||
uv = bm.loops.layers.uv.active
|
||||
polys = []
|
||||
if uv is not None:
|
||||
for f in bm.faces:
|
||||
polys.append([[l[uv].uv.x, l[uv].uv.y] for l in f.loops])
|
||||
bm.free()
|
||||
return polys
|
||||
|
||||
|
||||
def main():
|
||||
import bpy
|
||||
import json
|
||||
argv = sys.argv[sys.argv.index("--") + 1:]
|
||||
src, out_fbx, target = argv[0], argv[1], int(argv[2])
|
||||
unwrap_mode = argv[3] if len(argv) > 3 else "seam"
|
||||
overlap_tol = float(argv[4]) if len(argv) > 4 and argv[4] else UV_OVERLAP_TOL
|
||||
warnings = []
|
||||
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
imported = import_model_objects(src)
|
||||
meshes = [o for o in imported if o.type == 'MESH']
|
||||
skipped = [o.name for o in imported if o.type != 'MESH']
|
||||
if skipped:
|
||||
warnings.append("忽略非 mesh 对象:%s(本工具只处理静态网格)" % ", ".join(skipped))
|
||||
if not meshes:
|
||||
print("MT_SUMMARY " + json.dumps({"error": "输入模型中没有 mesh"}, ensure_ascii=False))
|
||||
return
|
||||
obj = join_meshes(meshes)
|
||||
|
||||
_clean_mesh(obj, warnings)
|
||||
_triangulate(obj)
|
||||
orig = len(obj.data.polygons)
|
||||
cur = orig
|
||||
if cur <= target:
|
||||
warnings.append("当前 %d 面 <= 目标 %d,跳过减面" % (cur, target))
|
||||
else:
|
||||
# collapse 结果是近似值;未达容差时按新面数修正 ratio 再来,最多 MAX_RETRY 轮
|
||||
for _ in range(1 + MAX_RETRY):
|
||||
_decimate(obj, decimate_ratio(target, cur))
|
||||
_triangulate(obj) # collapse triangulate 后仍可能残留非三角面
|
||||
cur = len(obj.data.polygons)
|
||||
if within_tolerance(target, cur) or cur <= target:
|
||||
break
|
||||
# 重试用尽仍超差且偏多:记录警告,交由上层决定是否可接受
|
||||
if not within_tolerance(target, cur) and cur > target:
|
||||
warnings.append("修正 %d 轮后仍超差:%d 面(目标 %d ±3%%)" % (MAX_RETRY, cur, target))
|
||||
|
||||
uv_info = _do_unwrap(obj, unwrap_mode, warnings, overlap_tol)
|
||||
uv_png = os.path.splitext(os.path.abspath(out_fbx))[0] + "_uv.png"
|
||||
uv_polys_json = os.path.splitext(os.path.abspath(out_fbx))[0] + "_uv.polys.json"
|
||||
uv_preview = None
|
||||
try:
|
||||
polys = _collect_uv_polygons(obj)
|
||||
os.makedirs(os.path.dirname(uv_polys_json), exist_ok=True)
|
||||
with open(uv_polys_json, "w", encoding="utf-8") as fp:
|
||||
json.dump(polys, fp)
|
||||
uv_preview = uv_png # 目标 PNG;实际绘制由 model_decimate(系统 Python + PIL)完成
|
||||
except Exception as e:
|
||||
warnings.append("UV 几何导出失败(%s),跳过观察图" % e)
|
||||
out_dir = os.path.dirname(os.path.abspath(out_fbx))
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
# 仅导出合并后的对象;整场景导出会把 armature/empty 等非 mesh 带进 _low.fbx
|
||||
bpy.ops.export_scene.fbx(filepath=out_fbx, use_selection=True,
|
||||
path_mode='STRIP', embed_textures=False)
|
||||
|
||||
print("MT_SUMMARY " + json.dumps(
|
||||
{"src": os.path.basename(src), "fbx": os.path.basename(out_fbx),
|
||||
"tris_before": orig, "tris_after": cur, "target": target,
|
||||
"uv": uv_info,
|
||||
"uv_preview": uv_preview,
|
||||
"uv_polys": uv_polys_json if uv_preview else None,
|
||||
"warnings": warnings},
|
||||
ensure_ascii=False))
|
||||
|
||||
|
||||
if __name__ == "__main__" and "--" in sys.argv:
|
||||
main()
|
||||
@@ -0,0 +1,50 @@
|
||||
"""高低模烘焙 CLI:在低模 UV 上烘出 normal/ao/color/metallic/roughness 五张贴图。
|
||||
产物命名对齐 model_translator.py 纯网格约定,可直接作为其输入。
|
||||
用法:python model_bake.py <高模.glb> <低模.fbx> [-o out_bake] [--size 2048]
|
||||
[--ray-distance N] [--samples 64] [--blender exe]"""
|
||||
import argparse
|
||||
import os
|
||||
|
||||
from bl_bake import output_stem
|
||||
from bl_decimate import model_format
|
||||
from mt_run import HERE, find_blender, run_blender_script
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description=__doc__)
|
||||
ap.add_argument("high", help="高模 FBX/GLB/glTF(带材质/贴图)")
|
||||
ap.add_argument("low", help="低模 FBX(须有 UV,见 model_decimate.py)")
|
||||
ap.add_argument("-o", "--out", default=os.path.join(HERE, "out_bake"))
|
||||
ap.add_argument("--size", type=int, default=2048, help="烘焙贴图边长")
|
||||
ap.add_argument("--ray-distance", type=float, default=None,
|
||||
help="射线距离(场景单位),默认低模包围盒对角线 2%%")
|
||||
ap.add_argument("--samples", type=int, default=64, help="AO 采样数")
|
||||
ap.add_argument("--blender", default=None)
|
||||
args = ap.parse_args()
|
||||
try:
|
||||
model_format(args.high)
|
||||
if model_format(args.low) != "fbx":
|
||||
ap.error("低模必须是 FBX")
|
||||
except ValueError as exc:
|
||||
ap.error(str(exc))
|
||||
|
||||
blender = find_blender(args.blender)
|
||||
stem = output_stem(os.path.splitext(os.path.basename(args.low))[0])
|
||||
outdir = os.path.join(args.out, stem)
|
||||
ray = "auto" if args.ray_distance is None else str(args.ray_distance)
|
||||
# -t 1:Blender 5.0 的 selected-to-active 烘焙射线求交是多线程的(cast_ray_highpoly
|
||||
# / TBB parallel_for),存在竞争会随机 EXCEPTION_ACCESS_VIOLATION 崩溃;单线程稳定
|
||||
s = run_blender_script(blender, "bl_bake.py",
|
||||
[args.high, args.low, outdir, str(args.size),
|
||||
ray, str(args.samples)],
|
||||
blender_args=("-t", "1"))
|
||||
print("== %s(高 %d 面 / 低 %d 面,射线距离 %.4f)-> %s" %
|
||||
(s["stem"], s["high_tris"], s["low_tris"], s["ray_distance"], outdir))
|
||||
for name, fname in s["outputs"].items():
|
||||
print(" %-10s %s" % (name, fname))
|
||||
for w in s["warnings"]:
|
||||
print(" [警告] %s" % w)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,64 @@
|
||||
"""FBX/GLB/glTF 减面 CLI:减到指定三角面数并重展 UV(seam 展开 + UVPM4 排布),输出 <名>_low.fbx。
|
||||
用法:python model_decimate.py src/well1500.glb --tris 5000 [-o dir]
|
||||
[--unwrap seam|smart] [--max-overlap 0.15] [--blender exe]"""
|
||||
import argparse
|
||||
import os
|
||||
|
||||
from bl_decimate import model_format
|
||||
from mt_run import find_blender, run_blender_script
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description=__doc__)
|
||||
ap.add_argument("input", help="高模 FBX/GLB/glTF 文件")
|
||||
ap.add_argument("--tris", type=int, required=True, help="目标三角面数")
|
||||
ap.add_argument("-o", "--out", default=None, help="输出目录(默认源文件同目录)")
|
||||
ap.add_argument("--unwrap", choices=("seam", "smart"), default="seam",
|
||||
help="UV 展开:seam=锐边接缝整岛展开(默认),smart=Smart UV Project")
|
||||
ap.add_argument("--max-overlap", type=float, default=None,
|
||||
help="UV 重叠容忍上限(0-1,默认内置 0.08);调高可让更高 seam 角度过门、减少碎岛")
|
||||
ap.add_argument("--blender", default=None)
|
||||
args = ap.parse_args()
|
||||
try:
|
||||
model_format(args.input)
|
||||
except ValueError as exc:
|
||||
ap.error(str(exc))
|
||||
if args.tris <= 0:
|
||||
ap.error("--tris 必须为正整数")
|
||||
if args.max_overlap is not None and not (0.0 < args.max_overlap <= 1.0):
|
||||
ap.error("--max-overlap 必须在 (0, 1] 内")
|
||||
|
||||
blender = find_blender(args.blender)
|
||||
name = os.path.splitext(os.path.basename(args.input))[0]
|
||||
outdir = args.out or os.path.dirname(os.path.abspath(args.input))
|
||||
out_fbx = os.path.join(outdir, name + "_low.fbx")
|
||||
|
||||
s = run_blender_script(blender, "bl_decimate.py",
|
||||
[args.input, out_fbx, str(args.tris), args.unwrap,
|
||||
"" if args.max_overlap is None else str(args.max_overlap)])
|
||||
|
||||
print("== %s: %d -> %d 面(目标 %d)-> %s" %
|
||||
(s["src"], s["tris_before"], s["tris_after"], s["target"], out_fbx))
|
||||
u = s["uv"]
|
||||
overlap = "%.1f%%" % (u["overlap"] * 100) if u["overlap"] is not None else "n/a"
|
||||
print(" UV: 模式=%s 岛数=%d 利用率=%.1f%% 翻转=%.1f%% 重叠=%s" %
|
||||
(u["mode"], u["islands"], u["fill"] * 100, u["flipped"] * 100, overlap))
|
||||
if s.get("uv_polys"):
|
||||
try:
|
||||
import json as _json
|
||||
from uv_preview import render_uv_png
|
||||
with open(s["uv_polys"], encoding="utf-8") as fp:
|
||||
polys = _json.load(fp)
|
||||
render_uv_png(polys, s["uv_preview"])
|
||||
print(" UV 观察图: %s" % s["uv_preview"])
|
||||
except Exception as e:
|
||||
print(" [警告] UV 观察图渲染失败:%s" % e)
|
||||
finally: # sidecar 是内部临时文件,无论渲染成败都清掉
|
||||
if os.path.exists(s["uv_polys"]):
|
||||
os.remove(s["uv_polys"])
|
||||
for w in s["warnings"]:
|
||||
print(" [警告] %s" % w)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,319 @@
|
||||
#!/usr/bin/env python3
|
||||
"""高模 → 低模减面 + 贴图烘焙(一键流水线)。
|
||||
|
||||
流程对齐 ClientTrunk ModelTools:
|
||||
高模(FBX/GLB/glTF)
|
||||
-> model_decimate:合并 Mesh、清理、三角化、减面、重展 UV -> <名>_low.fbx
|
||||
-> model_bake:Cycles selected-to-active
|
||||
-> 低模 FBX + color / normal / ao / metallic / roughness PNG
|
||||
|
||||
用法:
|
||||
python Tools/model_tools/model_lowpoly_bake.py high.glb -o Tools/output/prop --tris 5000 --size 2048
|
||||
python Tools/model_tools/model_lowpoly_bake.py --json "{\"high\":\"a.glb\",\"out\":\"out/a\",\"tris\":5000}" --result-only
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Sequence, Union
|
||||
|
||||
_HERE = Path(__file__).resolve().parent
|
||||
if str(_HERE) not in sys.path:
|
||||
sys.path.insert(0, str(_HERE))
|
||||
|
||||
from bl_bake import output_stem # noqa: E402
|
||||
from bl_decimate import model_format # noqa: E402
|
||||
from mt_run import find_blender, run_blender_script # noqa: E402
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
||||
|
||||
|
||||
class CallbackHub:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
callback_url: Optional[str] = None,
|
||||
callback_file: Optional[Union[str, Path]] = None,
|
||||
quiet: bool = False,
|
||||
) -> None:
|
||||
self.callback_url = callback_url
|
||||
self.callback_file = Path(callback_file) if callback_file else None
|
||||
self.quiet = quiet
|
||||
self.events: List[Dict[str, Any]] = []
|
||||
|
||||
def emit(self, event: str, **payload: Any) -> Dict[str, Any]:
|
||||
record = {"event": event, "ts": _now_iso(), **payload}
|
||||
self.events.append(record)
|
||||
if not self.quiet:
|
||||
print(json.dumps(record, ensure_ascii=False), flush=True)
|
||||
if self.callback_file:
|
||||
self.callback_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
self.callback_file.write_text(
|
||||
json.dumps(
|
||||
{"latest": record, "history": self.events},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
if self.callback_url:
|
||||
try:
|
||||
import urllib.request
|
||||
|
||||
data = json.dumps(record, ensure_ascii=False).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
self.callback_url,
|
||||
data=data,
|
||||
headers={
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
method="POST",
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=30.0) as resp:
|
||||
resp.read()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
if not self.quiet:
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"event": "callback_error",
|
||||
"ts": _now_iso(),
|
||||
"error": str(exc),
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
flush=True,
|
||||
)
|
||||
return record
|
||||
|
||||
|
||||
def _load_json_arg(raw: Optional[str]) -> Dict[str, Any]:
|
||||
if not raw:
|
||||
return {}
|
||||
if raw == "-":
|
||||
return json.load(sys.stdin)
|
||||
path = Path(raw)
|
||||
if path.is_file():
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
return json.loads(raw)
|
||||
|
||||
|
||||
def run_pipeline(
|
||||
*,
|
||||
high: str,
|
||||
out: str,
|
||||
tris: int = 5000,
|
||||
size: int = 2048,
|
||||
samples: int = 64,
|
||||
unwrap: str = "seam",
|
||||
max_overlap: Optional[float] = None,
|
||||
ray_distance: Optional[float] = None,
|
||||
blender: Optional[str] = None,
|
||||
keep_low: bool = True,
|
||||
callback_url: Optional[str] = None,
|
||||
callback_file: Optional[str] = None,
|
||||
quiet: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
callbacks = CallbackHub(
|
||||
callback_url=callback_url, callback_file=callback_file, quiet=quiet
|
||||
)
|
||||
high_path = Path(high).expanduser().resolve()
|
||||
if not high_path.is_file():
|
||||
raise FileNotFoundError(f"高模不存在: {high_path}")
|
||||
try:
|
||||
model_format(str(high_path))
|
||||
except ValueError as exc:
|
||||
raise ValueError(str(exc)) from exc
|
||||
if tris <= 0:
|
||||
raise ValueError("--tris 必须为正整数")
|
||||
|
||||
out_dir = Path(out).expanduser().resolve()
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
name = high_path.stem
|
||||
low_dir = out_dir / "_low_work" if not keep_low else out_dir
|
||||
low_dir.mkdir(parents=True, exist_ok=True)
|
||||
low_fbx = low_dir / f"{name}_low.fbx"
|
||||
stem = output_stem(f"{name}_low")
|
||||
bake_dir = out_dir / stem
|
||||
|
||||
blender_exe = find_blender(blender)
|
||||
callbacks.emit(
|
||||
"started",
|
||||
stage="开始高模转低模烘焙",
|
||||
progress=1,
|
||||
high=str(high_path),
|
||||
out=str(out_dir),
|
||||
tris=tris,
|
||||
size=size,
|
||||
blender=blender_exe,
|
||||
)
|
||||
|
||||
callbacks.emit("stage", stage="减面并重展 UV", progress=20)
|
||||
overlap_arg = "" if max_overlap is None else str(max_overlap)
|
||||
dec = run_blender_script(
|
||||
blender_exe,
|
||||
"bl_decimate.py",
|
||||
[str(high_path), str(low_fbx), str(tris), unwrap, overlap_arg],
|
||||
)
|
||||
callbacks.emit(
|
||||
"decimated",
|
||||
stage="减面完成",
|
||||
progress=50,
|
||||
tris_before=dec["tris_before"],
|
||||
tris_after=dec["tris_after"],
|
||||
low_fbx=str(low_fbx),
|
||||
uv=dec.get("uv"),
|
||||
warnings=dec.get("warnings") or [],
|
||||
)
|
||||
|
||||
# Optional UV preview (same as model_decimate.py)
|
||||
if dec.get("uv_polys"):
|
||||
try:
|
||||
from uv_preview import render_uv_png
|
||||
|
||||
with open(dec["uv_polys"], encoding="utf-8") as fp:
|
||||
polys = json.load(fp)
|
||||
render_uv_png(polys, dec["uv_preview"])
|
||||
callbacks.emit(
|
||||
"uv_preview",
|
||||
stage="UV 观察图已生成",
|
||||
progress=55,
|
||||
path=dec["uv_preview"],
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
callbacks.emit(
|
||||
"warning", stage=f"UV 观察图失败: {exc}", progress=55
|
||||
)
|
||||
finally:
|
||||
if os.path.exists(dec["uv_polys"]):
|
||||
os.remove(dec["uv_polys"])
|
||||
|
||||
callbacks.emit("stage", stage="高低模贴图烘焙", progress=60)
|
||||
ray = "auto" if ray_distance is None else str(ray_distance)
|
||||
bake = run_blender_script(
|
||||
blender_exe,
|
||||
"bl_bake.py",
|
||||
[
|
||||
str(high_path),
|
||||
str(low_fbx),
|
||||
str(bake_dir),
|
||||
str(size),
|
||||
ray,
|
||||
str(samples),
|
||||
],
|
||||
blender_args=("-t", "1"),
|
||||
)
|
||||
|
||||
result = {
|
||||
"ok": True,
|
||||
"status": "succeeded",
|
||||
"progress": 100,
|
||||
"high": str(high_path),
|
||||
"low_fbx": str(low_fbx),
|
||||
"bake_dir": str(bake_dir),
|
||||
"fbx": str(bake_dir / bake["fbx"]),
|
||||
"maps": {k: str(bake_dir / v) for k, v in bake["outputs"].items()},
|
||||
"tris_before": dec["tris_before"],
|
||||
"tris_after": bake["low_tris"],
|
||||
"high_tris": bake["high_tris"],
|
||||
"size": size,
|
||||
"ray_distance": bake["ray_distance"],
|
||||
"warnings": list(dec.get("warnings") or []) + list(bake.get("warnings") or []),
|
||||
"hint": "生成完成后请在 Doc/ResDesc_Model.md 登记该资源",
|
||||
}
|
||||
callbacks.emit("succeeded", **result)
|
||||
return result
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
p = argparse.ArgumentParser(
|
||||
prog="model_lowpoly_bake",
|
||||
description="高模减面 + Cycles 贴图烘焙(一键)",
|
||||
)
|
||||
p.add_argument("high", nargs="?", help="高模 FBX/GLB/glTF")
|
||||
p.add_argument("-o", "--out", help="输出目录")
|
||||
p.add_argument("--tris", type=int, default=5000, help="目标三角面数,默认 5000")
|
||||
p.add_argument("--size", type=int, default=2048, help="烘焙贴图边长,默认 2048")
|
||||
p.add_argument("--samples", type=int, default=64, help="AO 采样数,默认 64")
|
||||
p.add_argument(
|
||||
"--unwrap",
|
||||
choices=("seam", "smart"),
|
||||
default="seam",
|
||||
help="UV 展开模式,默认 seam",
|
||||
)
|
||||
p.add_argument("--max-overlap", type=float, default=None, help="UV 重叠容忍 (0,1]")
|
||||
p.add_argument("--ray-distance", type=float, default=None, help="烘焙射线距离")
|
||||
p.add_argument("--blender", default=None, help="blender.exe 路径")
|
||||
p.add_argument(
|
||||
"--callback-url", default=None, help="流程回调 URL:每个事件 POST JSON"
|
||||
)
|
||||
p.add_argument(
|
||||
"--callback-file",
|
||||
default=None,
|
||||
help="流程回调状态文件(latest/history)",
|
||||
)
|
||||
p.add_argument(
|
||||
"--json",
|
||||
dest="json_input",
|
||||
default=None,
|
||||
help='JSON 参数(对象字符串 / 文件路径 / "-" 读 stdin)',
|
||||
)
|
||||
p.add_argument("--result-only", action="store_true")
|
||||
p.add_argument("--quiet", action="store_true")
|
||||
return p
|
||||
|
||||
|
||||
def main(argv: Optional[Sequence[str]] = None) -> int:
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(argv)
|
||||
payload = _load_json_arg(args.json_input)
|
||||
quiet = bool(args.quiet or args.result_only or payload.get("quiet"))
|
||||
|
||||
high = payload.get("high") or args.high
|
||||
out = payload.get("out") or payload.get("output") or args.out
|
||||
if not high or not out:
|
||||
parser.error("必须提供高模路径与 -o/--out(或 JSON.high / JSON.out)")
|
||||
|
||||
try:
|
||||
result = run_pipeline(
|
||||
high=high,
|
||||
out=out,
|
||||
tris=int(payload.get("tris") or args.tris),
|
||||
size=int(payload.get("size") or args.size),
|
||||
samples=int(payload.get("samples") or args.samples),
|
||||
unwrap=payload.get("unwrap") or args.unwrap,
|
||||
max_overlap=(
|
||||
float(payload["max_overlap"])
|
||||
if payload.get("max_overlap") is not None
|
||||
else args.max_overlap
|
||||
),
|
||||
ray_distance=(
|
||||
float(payload["ray_distance"])
|
||||
if payload.get("ray_distance") is not None
|
||||
else args.ray_distance
|
||||
),
|
||||
blender=payload.get("blender") or args.blender,
|
||||
callback_url=payload.get("callback_url") or args.callback_url,
|
||||
callback_file=payload.get("callback_file") or args.callback_file,
|
||||
quiet=quiet,
|
||||
)
|
||||
except SystemExit:
|
||||
raise
|
||||
except Exception as exc: # noqa: BLE001
|
||||
err = {"ok": False, "error": str(exc)}
|
||||
print(json.dumps(err, ensure_ascii=False), flush=True)
|
||||
return 1
|
||||
|
||||
print(json.dumps(result, ensure_ascii=False), flush=True)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,67 @@
|
||||
"""find_blender / run_blender_script:model_decimate 与 model_bake 共享的
|
||||
headless Blender 运行器。"""
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
DEFAULT_BLENDER = r"D:\tools\Blender\blender-5.0.0-windows-x64\blender.exe"
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
|
||||
def find_blender(cli_arg):
|
||||
"""Resolve blender.exe from --blender / BLENDER_EXE / BLENDER_PATH / default / PATH."""
|
||||
candidates = (
|
||||
cli_arg,
|
||||
os.environ.get("BLENDER_EXE"),
|
||||
os.environ.get("BLENDER_PATH"),
|
||||
DEFAULT_BLENDER,
|
||||
shutil.which("blender"),
|
||||
)
|
||||
for cand in candidates:
|
||||
if not cand:
|
||||
continue
|
||||
path = cand
|
||||
# Allow BLENDER_PATH to point at a directory containing blender.exe
|
||||
if os.path.isdir(path):
|
||||
nested = os.path.join(path, "blender.exe")
|
||||
if os.path.isfile(nested):
|
||||
return nested
|
||||
continue
|
||||
if os.path.isfile(path):
|
||||
return path
|
||||
sys.exit(
|
||||
"找不到 blender.exe,请用 --blender 指定,或设置环境变量 BLENDER_EXE / BLENDER_PATH"
|
||||
)
|
||||
|
||||
|
||||
def run_blender_script(blender, script, script_args, blender_args=()):
|
||||
"""跑 HERE 下的 bl_*.py,返回 MT_SUMMARY JSON;失败或 summary 带 error 则退出。"""
|
||||
cmd = [
|
||||
blender,
|
||||
"-b",
|
||||
*blender_args,
|
||||
"--factory-startup",
|
||||
"--python",
|
||||
os.path.join(HERE, script),
|
||||
"--",
|
||||
] + list(script_args)
|
||||
proc = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
)
|
||||
summary = None
|
||||
for line in (proc.stdout or "").splitlines():
|
||||
if line.startswith("MT_SUMMARY "):
|
||||
summary = json.loads(line[len("MT_SUMMARY ") :])
|
||||
if proc.returncode != 0 or summary is None or "error" in summary:
|
||||
sys.stderr.write(
|
||||
(proc.stdout or "")[-2000:] + "\n" + (proc.stderr or "")[-2000:] + "\n"
|
||||
)
|
||||
err = summary["error"] if summary and "error" in summary else script
|
||||
sys.exit("Blender 执行失败:%s" % err)
|
||||
return summary
|
||||
@@ -0,0 +1,26 @@
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from bl_decimate import model_format
|
||||
|
||||
|
||||
class ModelFormatTests(unittest.TestCase):
|
||||
def test_fbx(self):
|
||||
self.assertEqual(model_format("asset.FBX"), "fbx")
|
||||
|
||||
def test_glb(self):
|
||||
self.assertEqual(model_format("asset.GLB"), "gltf")
|
||||
|
||||
def test_gltf(self):
|
||||
self.assertEqual(model_format("asset.gltf"), "gltf")
|
||||
|
||||
def test_unsupported(self):
|
||||
with self.assertRaisesRegex(ValueError, "unsupported model format"):
|
||||
model_format("asset.obj")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,31 @@
|
||||
"""UV 线框观察图渲染:把 UV 多边形(归一化 0-1 坐标)画成 PNG 供人工查阅。
|
||||
在系统 Python(有 Pillow)中运行——Blender 自带 Python 无 PIL,故 UV 几何由
|
||||
bl_decimate 导出为 JSON,本模块负责绘制。"""
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
|
||||
def _to_px(u, v, size, pad):
|
||||
"""UV(0-1, 原点左下) -> 像素(原点左上),含边距。"""
|
||||
span = size - 2 * pad
|
||||
x = pad + u * span
|
||||
y = pad + (1.0 - v) * span # V 轴翻转:图像 y 向下
|
||||
return x, y
|
||||
|
||||
|
||||
def render_uv_png(polygons, out_png, size=1024, pad=8,
|
||||
line=(30, 30, 30, 255), fill=(80, 140, 220, 64)):
|
||||
"""polygons: [[[u,v], ...], ...] 每个多边形一组 UV 顶点。
|
||||
白底 + 半透明填充 + 深色线框;写 PNG 到 out_png,返回 out_png。"""
|
||||
base = Image.new("RGBA", (size, size), (255, 255, 255, 255))
|
||||
overlay = Image.new("RGBA", (size, size), (0, 0, 0, 0))
|
||||
draw = ImageDraw.Draw(overlay, "RGBA")
|
||||
for poly in polygons:
|
||||
pts = [_to_px(u, v, size, pad) for (u, v) in poly]
|
||||
if len(pts) >= 3:
|
||||
draw.polygon(pts, fill=fill)
|
||||
for poly in polygons:
|
||||
pts = [_to_px(u, v, size, pad) for (u, v) in poly]
|
||||
if len(pts) >= 2:
|
||||
draw.line(pts + pts[:1], fill=line, width=1)
|
||||
Image.alpha_composite(base, overlay).convert("RGB").save(out_png, "PNG")
|
||||
return out_png
|
||||
@@ -0,0 +1 @@
|
||||
{"prompt": "simple green rounded UI button on transparent background", "output": "d:\\UD\\AI\\AIGame\\Tools\\output\\_vip_probe.png", "aspectRatio": "2048x2048", "model": "gpt-image-2-vip", "quality": "medium"}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.0 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 186 KiB |
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"latest": {
|
||||
"event": "succeeded",
|
||||
"ts": "2026-09-21T01:50:20Z",
|
||||
"ok": true,
|
||||
"id": "15-cbf99b28-89f1-4e3e-b4c2-916c03278a0e",
|
||||
"status": "succeeded",
|
||||
"progress": 100,
|
||||
"model": "gpt-image-2.5",
|
||||
"prompt": "Create a single full-body male character portrait in the exact same art style as the reference UI sheet: casual mobile game 2D art with soft 3D bubbly feel, chibi/SD proportions (large head, large expressive eyes, smaller body), thick clean rounded outlines with darker saturated color edges (not harsh black), soft digital painted shading, glossy highlights, vibrant tropical adventure palette. Character: young male explorer, messy stylized brown hair, teal goggles with gold rim on forehead, friendly confident smirk, dark green explorer vest over light brown shirt, polished high-production casual mobile RPG look. Plain soft cream parchment background, centered character only, no UI panels, no text, no buttons, no icons.",
|
||||
"files": [
|
||||
{
|
||||
"url": "https://file8.aitohumanize.com/file/1283f606a6104dbfb82243f689f1b416.png",
|
||||
"path": "D:\\UD\\AI\\AIGame\\Tools\\output\\male_explorer_test.png"
|
||||
}
|
||||
]
|
||||
},
|
||||
"history": [
|
||||
{
|
||||
"event": "started",
|
||||
"ts": "2026-09-21T01:49:20Z",
|
||||
"model": "gpt-image-2.5",
|
||||
"prompt": "Create a single full-body male character portrait in the exact same art style as the reference UI sheet: casual mobile game 2D art with soft 3D bubbly feel, chibi/SD proportions (large head, large expressive eyes, smaller body), thick clean rounded outlines with darker saturated color edges (not harsh black), soft digital painted shading, glossy highlights, vibrant tropical adventure palette. Character: young male explorer, messy stylized brown hair, teal goggles with gold rim on forehead, friendly confident smirk, dark green explorer vest over light brown shirt, polished high-production casual mobile RPG look. Plain soft cream parchment background, centered character only, no UI panels, no text, no buttons, no icons.",
|
||||
"output": "d:\\UD\\AI\\AIGame\\Tools\\output\\male_explorer_test.png",
|
||||
"image_count": 1,
|
||||
"reply_type": "json"
|
||||
},
|
||||
{
|
||||
"event": "submitted",
|
||||
"ts": "2026-09-21T01:50:16Z",
|
||||
"id": "15-cbf99b28-89f1-4e3e-b4c2-916c03278a0e",
|
||||
"status": "succeeded",
|
||||
"progress": 100
|
||||
},
|
||||
{
|
||||
"event": "succeeded",
|
||||
"ts": "2026-09-21T01:50:20Z",
|
||||
"ok": true,
|
||||
"id": "15-cbf99b28-89f1-4e3e-b4c2-916c03278a0e",
|
||||
"status": "succeeded",
|
||||
"progress": 100,
|
||||
"model": "gpt-image-2.5",
|
||||
"prompt": "Create a single full-body male character portrait in the exact same art style as the reference UI sheet: casual mobile game 2D art with soft 3D bubbly feel, chibi/SD proportions (large head, large expressive eyes, smaller body), thick clean rounded outlines with darker saturated color edges (not harsh black), soft digital painted shading, glossy highlights, vibrant tropical adventure palette. Character: young male explorer, messy stylized brown hair, teal goggles with gold rim on forehead, friendly confident smirk, dark green explorer vest over light brown shirt, polished high-production casual mobile RPG look. Plain soft cream parchment background, centered character only, no UI panels, no text, no buttons, no icons.",
|
||||
"files": [
|
||||
{
|
||||
"url": "https://file8.aitohumanize.com/file/1283f606a6104dbfb82243f689f1b416.png",
|
||||
"path": "D:\\UD\\AI\\AIGame\\Tools\\output\\male_explorer_test.png"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.6 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.8 MiB |
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"latest": {
|
||||
"event": "succeeded",
|
||||
"ts": "2026-09-21T02:39:06Z",
|
||||
"ok": true,
|
||||
"id": "9-9f2b6fdf-a411-44e5-b172-3848408bd4df",
|
||||
"status": "succeeded",
|
||||
"progress": 100,
|
||||
"model": "gpt-image-2-vip",
|
||||
"prompt": "Create a Unity UGUI sprite atlas sheet for slicing. Exact square canvas 2048x2048. Unused areas must be pure empty transparent (no checkerboard, no solid fill). NO text, NO labels, NO color hex annotations, NO watermarks, NO characters, NO mockup screenshots — only empty reusable UI chrome sprites ready for Sprite Editor slicing.\n\nStyle: casual candy mobile UI matching the reference — thick rounded corners, deeper same-hue outline 2-4px, soft white top gloss highlight, soft drop shadow, creamy warm panels, glossy plastic candy. Colors: cream panel #F6EBCF border #C89B5C; primary green #7ED321 / pressed #4E9A12; secondary blue #43B7F5 / pressed #1E84C8; disabled #B9C0C8; selected/focus #9EEB3B; close coral #FF5B5B; back button brown/wood circle; overlay #0B1724 ~75% alpha; progress/slider fill green; scrollbar thumb blue; tab active blue raised, tab inactive cream sunk; toast warm cream rounded bar; toggle on green / off grey with white knob.\n\nLayout top-left origin, ≥8px gaps, sprites ONLY in upper area (~1736x680 envelope); leave rest transparent:\n\nROW1 Y≈8:\n- ui_panel_main 480x256 cream empty panel\n- ui_panel_popup 360x240 cream empty popup\n- ui_panel_content 320x200 light cream empty card\n- ui_panel_header 400x80 wood/brown title bar strip empty\n- ui_overlay_dim 192x192 semi-transparent dark square\n- ui_divider_h 400x16 thin warm brown horizontal divider under header area\n\nROW2 Y≈280:\n- btn_primary_normal 224x88 glossy green empty\n- btn_primary_pressed 224x88 darker green empty\n- btn_primary_disabled 224x88 grey empty\n- btn_secondary_normal 224x88 glossy blue empty\n- btn_secondary_pressed 224x88 darker blue empty\n- btn_small_normal 160x64 small green empty\n- btn_small_pressed 160x64 small darker green empty\n- btn_close 72x72 coral red circle with white X\n- btn_back 72x72 circular back/chevron button\n- scrollbar_track_v 28x160 recessed vertical track\n- scrollbar_thumb_v 28x80 blue vertical thumb\n\nROW3 Y≈400:\n- input_bg_normal 360x72 recessed cream input empty\n- input_bg_focus 360x72 input with bright focus border empty\n- ui_list_item_normal 320x72 cream list row empty\n- ui_list_item_selected 320x72 list row green selected border empty\n- tab_active 200x72 raised blue tab empty\n- tab_inactive 200x72 sunk cream tab empty\n\nROW4 Y≈504:\n- progress_bg 400x32 dark recessed capsule\n- progress_fill 400x32 glossy green capsule\n- slider_track 320x28 dark recessed track\n- slider_fill 320x28 glossy green fill\n- slider_handle 56x56 white/blue glossy circle\n- toast_bg 280x80 cream rounded toast bar empty\n\nROW5 Y≈616:\n- toggle_bg_off 112x56 grey capsule\n- toggle_bg_on 112x56 green capsule\n- toggle_knob 48x48 white circle\n- checkbox_off 56x56 empty rounded square\n- checkbox_on 56x56 rounded square green check\n- ico_search 56x56 glossy search icon\n- ico_check 56x56 glossy check icon\n- ico_settings 56x56 glossy gear\n- ico_refresh 56x56 glossy refresh arrows\n\nClean edges for 9-slice, consistent top lighting, no overlapping sprites. Sprite atlas only, not a UI mockup.\n",
|
||||
"files": [
|
||||
{
|
||||
"url": "https://file8.aitohumanize.com/file/d3f0c46a9032404d9de23efce49fd4c1.png",
|
||||
"path": "D:\\UD\\AI\\AIGame\\Tools\\output\\ui_normal_atlas_gpt.png"
|
||||
}
|
||||
]
|
||||
},
|
||||
"history": [
|
||||
{
|
||||
"event": "started",
|
||||
"ts": "2026-09-21T02:37:58Z",
|
||||
"model": "gpt-image-2-vip",
|
||||
"prompt": "Create a Unity UGUI sprite atlas sheet for slicing. Exact square canvas 2048x2048. Unused areas must be pure empty transparent (no checkerboard, no solid fill). NO text, NO labels, NO color hex annotations, NO watermarks, NO characters, NO mockup screenshots — only empty reusable UI chrome sprites ready for Sprite Editor slicing.\n\nStyle: casual candy mobile UI matching the reference — thick rounded corners, deeper same-hue outline 2-4px, soft white top gloss highlight, soft drop shadow, creamy warm panels, glossy plastic candy. Colors: cream panel #F6EBCF border #C89B5C; primary green #7ED321 / pressed #4E9A12; secondary blue #43B7F5 / pressed #1E84C8; disabled #B9C0C8; selected/focus #9EEB3B; close coral #FF5B5B; back button brown/wood circle; overlay #0B1724 ~75% alpha; progress/slider fill green; scrollbar thumb blue; tab active blue raised, tab inactive cream sunk; toast warm cream rounded bar; toggle on green / off grey with white knob.\n\nLayout top-left origin, ≥8px gaps, sprites ONLY in upper area (~1736x680 envelope); leave rest transparent:\n\nROW1 Y≈8:\n- ui_panel_main 480x256 cream empty panel\n- ui_panel_popup 360x240 cream empty popup\n- ui_panel_content 320x200 light cream empty card\n- ui_panel_header 400x80 wood/brown title bar strip empty\n- ui_overlay_dim 192x192 semi-transparent dark square\n- ui_divider_h 400x16 thin warm brown horizontal divider under header area\n\nROW2 Y≈280:\n- btn_primary_normal 224x88 glossy green empty\n- btn_primary_pressed 224x88 darker green empty\n- btn_primary_disabled 224x88 grey empty\n- btn_secondary_normal 224x88 glossy blue empty\n- btn_secondary_pressed 224x88 darker blue empty\n- btn_small_normal 160x64 small green empty\n- btn_small_pressed 160x64 small darker green empty\n- btn_close 72x72 coral red circle with white X\n- btn_back 72x72 circular back/chevron button\n- scrollbar_track_v 28x160 recessed vertical track\n- scrollbar_thumb_v 28x80 blue vertical thumb\n\nROW3 Y≈400:\n- input_bg_normal 360x72 recessed cream input empty\n- input_bg_focus 360x72 input with bright focus border empty\n- ui_list_item_normal 320x72 cream list row empty\n- ui_list_item_selected 320x72 list row green selected border empty\n- tab_active 200x72 raised blue tab empty\n- tab_inactive 200x72 sunk cream tab empty\n\nROW4 Y≈504:\n- progress_bg 400x32 dark recessed capsule\n- progress_fill 400x32 glossy green capsule\n- slider_track 320x28 dark recessed track\n- slider_fill 320x28 glossy green fill\n- slider_handle 56x56 white/blue glossy circle\n- toast_bg 280x80 cream rounded toast bar empty\n\nROW5 Y≈616:\n- toggle_bg_off 112x56 grey capsule\n- toggle_bg_on 112x56 green capsule\n- toggle_knob 48x48 white circle\n- checkbox_off 56x56 empty rounded square\n- checkbox_on 56x56 rounded square green check\n- ico_search 56x56 glossy search icon\n- ico_check 56x56 glossy check icon\n- ico_settings 56x56 glossy gear\n- ico_refresh 56x56 glossy refresh arrows\n\nClean edges for 9-slice, consistent top lighting, no overlapping sprites. Sprite atlas only, not a UI mockup.\n",
|
||||
"output": "d:\\UD\\AI\\AIGame\\Tools\\output\\ui_normal_atlas_gpt.png",
|
||||
"aspect_ratio": "2048x2048",
|
||||
"image_count": 1,
|
||||
"reply_type": "json"
|
||||
},
|
||||
{
|
||||
"event": "submitted",
|
||||
"ts": "2026-09-21T02:39:00Z",
|
||||
"id": "9-9f2b6fdf-a411-44e5-b172-3848408bd4df",
|
||||
"status": "succeeded",
|
||||
"progress": 100
|
||||
},
|
||||
{
|
||||
"event": "succeeded",
|
||||
"ts": "2026-09-21T02:39:06Z",
|
||||
"ok": true,
|
||||
"id": "9-9f2b6fdf-a411-44e5-b172-3848408bd4df",
|
||||
"status": "succeeded",
|
||||
"progress": 100,
|
||||
"model": "gpt-image-2-vip",
|
||||
"prompt": "Create a Unity UGUI sprite atlas sheet for slicing. Exact square canvas 2048x2048. Unused areas must be pure empty transparent (no checkerboard, no solid fill). NO text, NO labels, NO color hex annotations, NO watermarks, NO characters, NO mockup screenshots — only empty reusable UI chrome sprites ready for Sprite Editor slicing.\n\nStyle: casual candy mobile UI matching the reference — thick rounded corners, deeper same-hue outline 2-4px, soft white top gloss highlight, soft drop shadow, creamy warm panels, glossy plastic candy. Colors: cream panel #F6EBCF border #C89B5C; primary green #7ED321 / pressed #4E9A12; secondary blue #43B7F5 / pressed #1E84C8; disabled #B9C0C8; selected/focus #9EEB3B; close coral #FF5B5B; back button brown/wood circle; overlay #0B1724 ~75% alpha; progress/slider fill green; scrollbar thumb blue; tab active blue raised, tab inactive cream sunk; toast warm cream rounded bar; toggle on green / off grey with white knob.\n\nLayout top-left origin, ≥8px gaps, sprites ONLY in upper area (~1736x680 envelope); leave rest transparent:\n\nROW1 Y≈8:\n- ui_panel_main 480x256 cream empty panel\n- ui_panel_popup 360x240 cream empty popup\n- ui_panel_content 320x200 light cream empty card\n- ui_panel_header 400x80 wood/brown title bar strip empty\n- ui_overlay_dim 192x192 semi-transparent dark square\n- ui_divider_h 400x16 thin warm brown horizontal divider under header area\n\nROW2 Y≈280:\n- btn_primary_normal 224x88 glossy green empty\n- btn_primary_pressed 224x88 darker green empty\n- btn_primary_disabled 224x88 grey empty\n- btn_secondary_normal 224x88 glossy blue empty\n- btn_secondary_pressed 224x88 darker blue empty\n- btn_small_normal 160x64 small green empty\n- btn_small_pressed 160x64 small darker green empty\n- btn_close 72x72 coral red circle with white X\n- btn_back 72x72 circular back/chevron button\n- scrollbar_track_v 28x160 recessed vertical track\n- scrollbar_thumb_v 28x80 blue vertical thumb\n\nROW3 Y≈400:\n- input_bg_normal 360x72 recessed cream input empty\n- input_bg_focus 360x72 input with bright focus border empty\n- ui_list_item_normal 320x72 cream list row empty\n- ui_list_item_selected 320x72 list row green selected border empty\n- tab_active 200x72 raised blue tab empty\n- tab_inactive 200x72 sunk cream tab empty\n\nROW4 Y≈504:\n- progress_bg 400x32 dark recessed capsule\n- progress_fill 400x32 glossy green capsule\n- slider_track 320x28 dark recessed track\n- slider_fill 320x28 glossy green fill\n- slider_handle 56x56 white/blue glossy circle\n- toast_bg 280x80 cream rounded toast bar empty\n\nROW5 Y≈616:\n- toggle_bg_off 112x56 grey capsule\n- toggle_bg_on 112x56 green capsule\n- toggle_knob 48x48 white circle\n- checkbox_off 56x56 empty rounded square\n- checkbox_on 56x56 rounded square green check\n- ico_search 56x56 glossy search icon\n- ico_check 56x56 glossy check icon\n- ico_settings 56x56 glossy gear\n- ico_refresh 56x56 glossy refresh arrows\n\nClean edges for 9-slice, consistent top lighting, no overlapping sprites. Sprite atlas only, not a UI mockup.\n",
|
||||
"files": [
|
||||
{
|
||||
"url": "https://file8.aitohumanize.com/file/d3f0c46a9032404d9de23efce49fd4c1.png",
|
||||
"path": "D:\\UD\\AI\\AIGame\\Tools\\output\\ui_normal_atlas_gpt.png"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"prompt": "Create a Unity UGUI sprite atlas sheet for slicing. Exact square canvas 2048x2048. Unused areas must be pure empty transparent (no checkerboard, no solid fill). NO text, NO labels, NO color hex annotations, NO watermarks, NO characters, NO mockup screenshots — only empty reusable UI chrome sprites ready for Sprite Editor slicing.\n\nStyle: casual candy mobile UI matching the reference — thick rounded corners, deeper same-hue outline 2-4px, soft white top gloss highlight, soft drop shadow, creamy warm panels, glossy plastic candy. Colors: cream panel #F6EBCF border #C89B5C; primary green #7ED321 / pressed #4E9A12; secondary blue #43B7F5 / pressed #1E84C8; disabled #B9C0C8; selected/focus #9EEB3B; close coral #FF5B5B; back button brown/wood circle; overlay #0B1724 ~75% alpha; progress/slider fill green; scrollbar thumb blue; tab active blue raised, tab inactive cream sunk; toast warm cream rounded bar; toggle on green / off grey with white knob.\n\nLayout top-left origin, ≥8px gaps, sprites ONLY in upper area (~1736x680 envelope); leave rest transparent:\n\nROW1 Y≈8:\n- ui_panel_main 480x256 cream empty panel\n- ui_panel_popup 360x240 cream empty popup\n- ui_panel_content 320x200 light cream empty card\n- ui_panel_header 400x80 wood/brown title bar strip empty\n- ui_overlay_dim 192x192 semi-transparent dark square\n- ui_divider_h 400x16 thin warm brown horizontal divider under header area\n\nROW2 Y≈280:\n- btn_primary_normal 224x88 glossy green empty\n- btn_primary_pressed 224x88 darker green empty\n- btn_primary_disabled 224x88 grey empty\n- btn_secondary_normal 224x88 glossy blue empty\n- btn_secondary_pressed 224x88 darker blue empty\n- btn_small_normal 160x64 small green empty\n- btn_small_pressed 160x64 small darker green empty\n- btn_close 72x72 coral red circle with white X\n- btn_back 72x72 circular back/chevron button\n- scrollbar_track_v 28x160 recessed vertical track\n- scrollbar_thumb_v 28x80 blue vertical thumb\n\nROW3 Y≈400:\n- input_bg_normal 360x72 recessed cream input empty\n- input_bg_focus 360x72 input with bright focus border empty\n- ui_list_item_normal 320x72 cream list row empty\n- ui_list_item_selected 320x72 list row green selected border empty\n- tab_active 200x72 raised blue tab empty\n- tab_inactive 200x72 sunk cream tab empty\n\nROW4 Y≈504:\n- progress_bg 400x32 dark recessed capsule\n- progress_fill 400x32 glossy green capsule\n- slider_track 320x28 dark recessed track\n- slider_fill 320x28 glossy green fill\n- slider_handle 56x56 white/blue glossy circle\n- toast_bg 280x80 cream rounded toast bar empty\n\nROW5 Y≈616:\n- toggle_bg_off 112x56 grey capsule\n- toggle_bg_on 112x56 green capsule\n- toggle_knob 48x48 white circle\n- checkbox_off 56x56 empty rounded square\n- checkbox_on 56x56 rounded square green check\n- ico_search 56x56 glossy search icon\n- ico_check 56x56 glossy check icon\n- ico_settings 56x56 glossy gear\n- ico_refresh 56x56 glossy refresh arrows\n\nClean edges for 9-slice, consistent top lighting, no overlapping sprites. Sprite atlas only, not a UI mockup.\n", "images": ["d:\\UD\\AI\\AIGame\\Doc\\ui_design.jpg"], "output": "d:\\UD\\AI\\AIGame\\Tools\\output\\ui_normal_atlas_gpt.png", "aspectRatio": "2048x2048", "model": "gpt-image-2-vip", "quality": "medium", "background": "transparent", "callback_file": "d:\\UD\\AI\\AIGame\\Tools\\output\\ui_normal_atlas_gpt_status.json"}
|
||||
@@ -0,0 +1,55 @@
|
||||
Create a Unity UGUI sprite atlas sheet for slicing. Exact square canvas 2048x2048. Unused areas must be pure empty transparent (no checkerboard, no solid fill). NO text, NO labels, NO color hex annotations, NO watermarks, NO characters, NO mockup screenshots — only empty reusable UI chrome sprites ready for Sprite Editor slicing.
|
||||
|
||||
Style: casual candy mobile UI matching the reference — thick rounded corners, deeper same-hue outline 2-4px, soft white top gloss highlight, soft drop shadow, creamy warm panels, glossy plastic candy. Colors: cream panel #F6EBCF border #C89B5C; primary green #7ED321 / pressed #4E9A12; secondary blue #43B7F5 / pressed #1E84C8; disabled #B9C0C8; selected/focus #9EEB3B; close coral #FF5B5B; back button brown/wood circle; overlay #0B1724 ~75% alpha; progress/slider fill green; scrollbar thumb blue; tab active blue raised, tab inactive cream sunk; toast warm cream rounded bar; toggle on green / off grey with white knob.
|
||||
|
||||
Layout top-left origin, ≥8px gaps, sprites ONLY in upper area (~1736x680 envelope); leave rest transparent:
|
||||
|
||||
ROW1 Y≈8:
|
||||
- ui_panel_main 480x256 cream empty panel
|
||||
- ui_panel_popup 360x240 cream empty popup
|
||||
- ui_panel_content 320x200 light cream empty card
|
||||
- ui_panel_header 400x80 wood/brown title bar strip empty
|
||||
- ui_overlay_dim 192x192 semi-transparent dark square
|
||||
- ui_divider_h 400x16 thin warm brown horizontal divider under header area
|
||||
|
||||
ROW2 Y≈280:
|
||||
- btn_primary_normal 224x88 glossy green empty
|
||||
- btn_primary_pressed 224x88 darker green empty
|
||||
- btn_primary_disabled 224x88 grey empty
|
||||
- btn_secondary_normal 224x88 glossy blue empty
|
||||
- btn_secondary_pressed 224x88 darker blue empty
|
||||
- btn_small_normal 160x64 small green empty
|
||||
- btn_small_pressed 160x64 small darker green empty
|
||||
- btn_close 72x72 coral red circle with white X
|
||||
- btn_back 72x72 circular back/chevron button
|
||||
- scrollbar_track_v 28x160 recessed vertical track
|
||||
- scrollbar_thumb_v 28x80 blue vertical thumb
|
||||
|
||||
ROW3 Y≈400:
|
||||
- input_bg_normal 360x72 recessed cream input empty
|
||||
- input_bg_focus 360x72 input with bright focus border empty
|
||||
- ui_list_item_normal 320x72 cream list row empty
|
||||
- ui_list_item_selected 320x72 list row green selected border empty
|
||||
- tab_active 200x72 raised blue tab empty
|
||||
- tab_inactive 200x72 sunk cream tab empty
|
||||
|
||||
ROW4 Y≈504:
|
||||
- progress_bg 400x32 dark recessed capsule
|
||||
- progress_fill 400x32 glossy green capsule
|
||||
- slider_track 320x28 dark recessed track
|
||||
- slider_fill 320x28 glossy green fill
|
||||
- slider_handle 56x56 white/blue glossy circle
|
||||
- toast_bg 280x80 cream rounded toast bar empty
|
||||
|
||||
ROW5 Y≈616:
|
||||
- toggle_bg_off 112x56 grey capsule
|
||||
- toggle_bg_on 112x56 green capsule
|
||||
- toggle_knob 48x48 white circle
|
||||
- checkbox_off 56x56 empty rounded square
|
||||
- checkbox_on 56x56 rounded square green check
|
||||
- ico_search 56x56 glossy search icon
|
||||
- ico_check 56x56 glossy check icon
|
||||
- ico_settings 56x56 glossy gear
|
||||
- ico_refresh 56x56 glossy refresh arrows
|
||||
|
||||
Clean edges for 9-slice, consistent top lighting, no overlapping sprites. Sprite atlas only, not a UI mockup.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 707 KiB |
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"latest": {
|
||||
"event": "succeeded",
|
||||
"ts": "2026-09-21T02:20:46Z",
|
||||
"ok": true,
|
||||
"id": "2-1d1db0b6-903e-4a43-a1d9-284a7b4ecefd",
|
||||
"status": "succeeded",
|
||||
"progress": 100,
|
||||
"model": "gpt-image-2.5",
|
||||
"prompt": "Create a Unity UGUI sprite atlas sheet for slicing. Square canvas 1:1. Transparent checkerboard-free pure empty transparent background in unused areas. NO text, NO labels, NO watermarks, NO sample characters, NO mockup screenshots 鈥?only empty reusable UI chrome sprites.\n\nStyle: casual candy mobile UI matching the reference 鈥?thick rounded corners, deeper same-hue outline stroke 2-4px, soft white top gloss highlight, soft drop shadow, creamy warm panels, glossy plastic candy look. Colors: cream panel #F6EBCF with brown border #C89B5C; primary green #7ED321 / dark #4E9A12; secondary blue #43B7F5 / dark #1E84C8; disabled grey #B9C0C8; focus/selected accent bright green #9EEB3B; close button coral red #FF5B5B; overlay dark #0B1724 at ~75% alpha; progress/slider fill green; scrollbar thumb blue.\n\nLayout (top-left origin, compact packing, 鈮?px gaps between sprites). Place sprites ONLY in upper-left area; leave rest of canvas empty/transparent:\n\nROW1 Y鈮?:\n- ui_panel_main 480x256 cream rounded panel empty\n- ui_panel_popup 360x240 cream rounded popup empty\n- ui_panel_content 320x200 light cream card empty\n- ui_overlay_dim 192x192 semi-transparent dark rectangle\n\nROW2 Y鈮?80:\n- btn_primary_normal 224x88 glossy green capsule button empty\n- btn_primary_pressed 224x88 darker green pressed button empty\n- btn_primary_disabled 224x88 grey disabled button empty\n- btn_secondary_normal 224x88 glossy blue capsule button empty\n- btn_close 72x72 coral red circular X close icon\n- scrollbar_track_v 28x160 thin recessed vertical track\n- scrollbar_thumb_v 28x80 blue rounded vertical thumb\n\nROW3 Y鈮?00:\n- input_bg_normal 360x72 recessed light input field empty\n- input_bg_focus 360x72 same with bright blue/green focus border\n- ui_list_item_normal 320x72 cream list row empty\n- ui_list_item_selected 320x72 cream list row with bright green selected border\n\nROW4 Y鈮?04:\n- progress_bg 400x32 dark recessed capsule track\n- progress_fill 400x32 glossy green capsule fill\n- slider_track 320x28 dark recessed track\n- slider_fill 320x28 glossy green fill\n- slider_handle 56x56 white/blue glossy circle knob\n\nROW5 Y鈮?60:\n- checkbox_off 56x56 rounded square empty\n- checkbox_on 56x56 rounded square with green check\n- ico_settings 56x56 glossy gear icon\n- ico_refresh 56x56 glossy refresh arrows icon\n\nKeep edges clean for 9-slice; consistent lighting from top; no overlapping sprites.\n",
|
||||
"files": [
|
||||
{
|
||||
"url": "https://file2.aitohumanize.com/file/434f424b6ab04850b2e7181973b68491.png",
|
||||
"path": "D:\\UD\\AI\\AIGame\\Tools\\output\\ui_simple_atlas_gpt.png"
|
||||
}
|
||||
]
|
||||
},
|
||||
"history": [
|
||||
{
|
||||
"event": "started",
|
||||
"ts": "2026-09-21T02:19:52Z",
|
||||
"model": "gpt-image-2.5",
|
||||
"prompt": "Create a Unity UGUI sprite atlas sheet for slicing. Square canvas 1:1. Transparent checkerboard-free pure empty transparent background in unused areas. NO text, NO labels, NO watermarks, NO sample characters, NO mockup screenshots 鈥?only empty reusable UI chrome sprites.\n\nStyle: casual candy mobile UI matching the reference 鈥?thick rounded corners, deeper same-hue outline stroke 2-4px, soft white top gloss highlight, soft drop shadow, creamy warm panels, glossy plastic candy look. Colors: cream panel #F6EBCF with brown border #C89B5C; primary green #7ED321 / dark #4E9A12; secondary blue #43B7F5 / dark #1E84C8; disabled grey #B9C0C8; focus/selected accent bright green #9EEB3B; close button coral red #FF5B5B; overlay dark #0B1724 at ~75% alpha; progress/slider fill green; scrollbar thumb blue.\n\nLayout (top-left origin, compact packing, 鈮?px gaps between sprites). Place sprites ONLY in upper-left area; leave rest of canvas empty/transparent:\n\nROW1 Y鈮?:\n- ui_panel_main 480x256 cream rounded panel empty\n- ui_panel_popup 360x240 cream rounded popup empty\n- ui_panel_content 320x200 light cream card empty\n- ui_overlay_dim 192x192 semi-transparent dark rectangle\n\nROW2 Y鈮?80:\n- btn_primary_normal 224x88 glossy green capsule button empty\n- btn_primary_pressed 224x88 darker green pressed button empty\n- btn_primary_disabled 224x88 grey disabled button empty\n- btn_secondary_normal 224x88 glossy blue capsule button empty\n- btn_close 72x72 coral red circular X close icon\n- scrollbar_track_v 28x160 thin recessed vertical track\n- scrollbar_thumb_v 28x80 blue rounded vertical thumb\n\nROW3 Y鈮?00:\n- input_bg_normal 360x72 recessed light input field empty\n- input_bg_focus 360x72 same with bright blue/green focus border\n- ui_list_item_normal 320x72 cream list row empty\n- ui_list_item_selected 320x72 cream list row with bright green selected border\n\nROW4 Y鈮?04:\n- progress_bg 400x32 dark recessed capsule track\n- progress_fill 400x32 glossy green capsule fill\n- slider_track 320x28 dark recessed track\n- slider_fill 320x28 glossy green fill\n- slider_handle 56x56 white/blue glossy circle knob\n\nROW5 Y鈮?60:\n- checkbox_off 56x56 rounded square empty\n- checkbox_on 56x56 rounded square with green check\n- ico_settings 56x56 glossy gear icon\n- ico_refresh 56x56 glossy refresh arrows icon\n\nKeep edges clean for 9-slice; consistent lighting from top; no overlapping sprites.\n",
|
||||
"output": "d:\\UD\\AI\\AIGame\\Tools\\output\\ui_simple_atlas_gpt.png",
|
||||
"image_count": 1,
|
||||
"reply_type": "json"
|
||||
},
|
||||
{
|
||||
"event": "submitted",
|
||||
"ts": "2026-09-21T02:20:42Z",
|
||||
"id": "2-1d1db0b6-903e-4a43-a1d9-284a7b4ecefd",
|
||||
"status": "succeeded",
|
||||
"progress": 100
|
||||
},
|
||||
{
|
||||
"event": "succeeded",
|
||||
"ts": "2026-09-21T02:20:46Z",
|
||||
"ok": true,
|
||||
"id": "2-1d1db0b6-903e-4a43-a1d9-284a7b4ecefd",
|
||||
"status": "succeeded",
|
||||
"progress": 100,
|
||||
"model": "gpt-image-2.5",
|
||||
"prompt": "Create a Unity UGUI sprite atlas sheet for slicing. Square canvas 1:1. Transparent checkerboard-free pure empty transparent background in unused areas. NO text, NO labels, NO watermarks, NO sample characters, NO mockup screenshots 鈥?only empty reusable UI chrome sprites.\n\nStyle: casual candy mobile UI matching the reference 鈥?thick rounded corners, deeper same-hue outline stroke 2-4px, soft white top gloss highlight, soft drop shadow, creamy warm panels, glossy plastic candy look. Colors: cream panel #F6EBCF with brown border #C89B5C; primary green #7ED321 / dark #4E9A12; secondary blue #43B7F5 / dark #1E84C8; disabled grey #B9C0C8; focus/selected accent bright green #9EEB3B; close button coral red #FF5B5B; overlay dark #0B1724 at ~75% alpha; progress/slider fill green; scrollbar thumb blue.\n\nLayout (top-left origin, compact packing, 鈮?px gaps between sprites). Place sprites ONLY in upper-left area; leave rest of canvas empty/transparent:\n\nROW1 Y鈮?:\n- ui_panel_main 480x256 cream rounded panel empty\n- ui_panel_popup 360x240 cream rounded popup empty\n- ui_panel_content 320x200 light cream card empty\n- ui_overlay_dim 192x192 semi-transparent dark rectangle\n\nROW2 Y鈮?80:\n- btn_primary_normal 224x88 glossy green capsule button empty\n- btn_primary_pressed 224x88 darker green pressed button empty\n- btn_primary_disabled 224x88 grey disabled button empty\n- btn_secondary_normal 224x88 glossy blue capsule button empty\n- btn_close 72x72 coral red circular X close icon\n- scrollbar_track_v 28x160 thin recessed vertical track\n- scrollbar_thumb_v 28x80 blue rounded vertical thumb\n\nROW3 Y鈮?00:\n- input_bg_normal 360x72 recessed light input field empty\n- input_bg_focus 360x72 same with bright blue/green focus border\n- ui_list_item_normal 320x72 cream list row empty\n- ui_list_item_selected 320x72 cream list row with bright green selected border\n\nROW4 Y鈮?04:\n- progress_bg 400x32 dark recessed capsule track\n- progress_fill 400x32 glossy green capsule fill\n- slider_track 320x28 dark recessed track\n- slider_fill 320x28 glossy green fill\n- slider_handle 56x56 white/blue glossy circle knob\n\nROW5 Y鈮?60:\n- checkbox_off 56x56 rounded square empty\n- checkbox_on 56x56 rounded square with green check\n- ico_settings 56x56 glossy gear icon\n- ico_refresh 56x56 glossy refresh arrows icon\n\nKeep edges clean for 9-slice; consistent lighting from top; no overlapping sprites.\n",
|
||||
"files": [
|
||||
{
|
||||
"url": "https://file2.aitohumanize.com/file/434f424b6ab04850b2e7181973b68491.png",
|
||||
"path": "D:\\UD\\AI\\AIGame\\Tools\\output\\ui_simple_atlas_gpt.png"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
Create a Unity UGUI sprite atlas sheet for slicing. Square canvas 1:1. Transparent checkerboard-free pure empty transparent background in unused areas. NO text, NO labels, NO watermarks, NO sample characters, NO mockup screenshots — only empty reusable UI chrome sprites.
|
||||
|
||||
Style: casual candy mobile UI matching the reference — thick rounded corners, deeper same-hue outline stroke 2-4px, soft white top gloss highlight, soft drop shadow, creamy warm panels, glossy plastic candy look. Colors: cream panel #F6EBCF with brown border #C89B5C; primary green #7ED321 / dark #4E9A12; secondary blue #43B7F5 / dark #1E84C8; disabled grey #B9C0C8; focus/selected accent bright green #9EEB3B; close button coral red #FF5B5B; overlay dark #0B1724 at ~75% alpha; progress/slider fill green; scrollbar thumb blue.
|
||||
|
||||
Layout (top-left origin, compact packing, ≥8px gaps between sprites). Place sprites ONLY in upper-left area; leave rest of canvas empty/transparent:
|
||||
|
||||
ROW1 Y≈8:
|
||||
- ui_panel_main 480x256 cream rounded panel empty
|
||||
- ui_panel_popup 360x240 cream rounded popup empty
|
||||
- ui_panel_content 320x200 light cream card empty
|
||||
- ui_overlay_dim 192x192 semi-transparent dark rectangle
|
||||
|
||||
ROW2 Y≈280:
|
||||
- btn_primary_normal 224x88 glossy green capsule button empty
|
||||
- btn_primary_pressed 224x88 darker green pressed button empty
|
||||
- btn_primary_disabled 224x88 grey disabled button empty
|
||||
- btn_secondary_normal 224x88 glossy blue capsule button empty
|
||||
- btn_close 72x72 coral red circular X close icon
|
||||
- scrollbar_track_v 28x160 thin recessed vertical track
|
||||
- scrollbar_thumb_v 28x80 blue rounded vertical thumb
|
||||
|
||||
ROW3 Y≈400:
|
||||
- input_bg_normal 360x72 recessed light input field empty
|
||||
- input_bg_focus 360x72 same with bright blue/green focus border
|
||||
- ui_list_item_normal 320x72 cream list row empty
|
||||
- ui_list_item_selected 320x72 cream list row with bright green selected border
|
||||
|
||||
ROW4 Y≈504:
|
||||
- progress_bg 400x32 dark recessed capsule track
|
||||
- progress_fill 400x32 glossy green capsule fill
|
||||
- slider_track 320x28 dark recessed track
|
||||
- slider_fill 320x28 glossy green fill
|
||||
- slider_handle 56x56 white/blue glossy circle knob
|
||||
|
||||
ROW5 Y≈560:
|
||||
- checkbox_off 56x56 rounded square empty
|
||||
- checkbox_on 56x56 rounded square with green check
|
||||
- ico_settings 56x56 glossy gear icon
|
||||
- ico_refresh 56x56 glossy refresh arrows icon
|
||||
|
||||
Keep edges clean for 9-slice; consistent lighting from top; no overlapping sprites.
|
||||
@@ -0,0 +1,10 @@
|
||||
# Hunyuan 3D (Playwright automation)
|
||||
# Python >= 3.9
|
||||
playwright>=1.40.0
|
||||
|
||||
# After install, ensure Microsoft Edge is available, or run:
|
||||
# playwright install msedge
|
||||
#
|
||||
# Optional env:
|
||||
# HUNYUAN_SESSION_DIR
|
||||
# HUNYUAN_BROWSER_CHANNEL (default: msedge)
|
||||
@@ -0,0 +1,4 @@
|
||||
# Base tools (gpt-image etc.): stdlib only, Python >= 3.9
|
||||
|
||||
# model_tools UV preview (optional but recommended)
|
||||
Pillow>=10.0.0
|
||||
Reference in New Issue
Block a user