Meshy 3D — 生成 + 打印 是做什么的?
直接与 Meshy AI API 通信,以生成和打印 3D 资源。涵盖完整生命周期:API 密钥设置、任务创建、指数退避轮询、下载、多步骤管线以及集成切片软件的 3D 打印准备。
安全清单
访问的环境变量:
MESHY_API_KEY— 仅在 HTTPAuthorization: Bearer头中发送的 API 认证令牌。永不记录,永不写入除当前工作目录中.env文件以外的任何文件(仅在用户明确要求时)。
外部网络端点:
https://api.meshy.ai— Meshy AI API(任务创建、状态轮询、模型/图像下载)
文件系统访问:
- 读取:仅当前工作目录中的
.env文件(API 密钥查找) - 写入:仅当前工作目录中的
.env文件(API 密钥存储,仅根据用户请求) - 写入:当前工作目录中的
./meshy_output/(下载的模型文件、元数据) - 读取:用户显式提供的文件(例如,为图像转 3D 传递的本地图像),仅以用户指定的确切路径访问
- 不访问主目录、shell 配置文件或上述路径之外的任何路径
离开此机器的数据:
- 发送到
api.meshy.ai的 API 请求包括MESHY_API_KEY(在 Authorization 头中)以及用户提供的文本提示或图像 URL。不传输其他本地数据。下载的模型文件仅保存在本地。
重要提示:首次使用会话通知
当此技能在会话中首次激活时,请通知用户:
所有生成的文件都将保存到当前工作目录中的
meshy_output/中。每个项目都有自己的文件夹({YYYYMMDD_HHmmss}_{prompt}_{id}/),其中包含模型文件、纹理、缩略图和元数据。历史记录保存在meshy_output/history.json中。
此通知每个会话只需说一次。
重要提示:文件组织
所有下载的文件必须放入当前工作目录中结构化的 meshy_output/ 目录中。不要随意散放文件。
- 每个项目:
meshy_output/{YYYYMMDD_HHmmss}_{prompt_slug}_{task_id_prefix}/ - 链式任务(预览 → 精细处理 → 绑定)重用相同的
project_dir - 按项目在
metadata.json中跟踪任务,并在全局history.json中跟踪 - 在下载模型的同时自动下载缩略图
重要提示:Shell 命令规则
仅使用标准的 POSIX 工具。不要使用 rg、fd、bat、exa/eza。
重要提示:正确运行长时间任务
Meshy 生成需要 1–5 分钟。将整个创建 → 轮询 → 下载流程编写为一个 Python 脚本,并在单个 Bash 调用中执行。使用 python3 -u script.py 实现无缓冲输出。任务在 99% 处停留 30–120 秒是正常的最终处理阶段 — 请勿中断。
步骤 0:API 密钥检测(始终首先运行)
仅检查当前会话环境和当前工作目录中的 .env 文件。不要扫描主目录或 shell 配置文件。
echo "=== Meshy API 密钥检测 ==="
# 1. 检查当前环境变量
if [ -n "$MESHY_API_KEY" ]; then
echo "ENV_VAR: 已找到 (${MESHY_API_KEY:0:8}...)"
else
echo "ENV_VAR: 未找到"
fi
# 2. 仅检查当前工作目录中的 .env
if [ -f ".env" ] && grep -q "MESHY_API_KEY" ".env" 2>/dev/null; then
echo "DOTENV(.env): 已找到"
export MESHY_API_KEY=$(grep "^MESHY_API_KEY=" ".env" | head -1 | cut -d'=' -f2- | tr -d '"\'')
fi
# 3. 最终状态
if [ -n "$MESHY_API_KEY" ]; then
echo "就绪: key=${MESHY_API_KEY:0:8}..."
else
echo "就绪: NO_KEY_FOUND"
fi
# 4. Python requests 检查
python3 -c "import requests; print('PYTHON_REQUESTS: OK')" 2>/dev/null || echo "PYTHON_REQUESTS: 缺失 (运行: pip install requests)"
echo "=== 检测完成 ==="
检测后的决策
- 找到密钥 → 继续步骤 1。
- 未找到密钥 → 转到步骤 0a。
- 缺少 Python requests → 运行
pip install requests。
步骤 0a:API 密钥设置(仅在未找到密钥时)
告知用户:
要使用 Meshy API,您需要一个 API 密钥:
- 前往 https://www.meshy.ai/settings/api
- 点击 "Create API Key",为其命名,然后复制密钥(以
msy_开头)- 该密钥仅显示一次 — 请将其保存在安全的地方
注意: API 访问需要 Pro 计划或以上。免费套餐账户无法创建 API 密钥。
一旦用户提供密钥,为当前会话设置密钥,并可选择持久化到 .env:
# 仅为当前会话设置
export MESHY_API_KEY="msy_PASTE_KEY_HERE"
# 验证密钥
STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
-H "Authorization: Bearer $MESHY_API_KEY" \
https://api.meshy.ai/openapi/v1/balance)
if [ "$STATUS" = "200" ]; then
BALANCE=$(curl -s -H "Authorization: Bearer $MESHY_API_KEY" https://api.meshy.ai/openapi/v1/balance)
echo "密钥有效。$BALANCE"
else
echo "密钥无效 (HTTP $STATUS)。请检查密钥并重试。"
fi
要持久化密钥(仅限当前项目):
# 写入当前工作目录的 .env 文件
echo 'MESHY_API_KEY=msy_PASTE_KEY_HERE' >> .env
echo "已保存到 .env"
# 重要:添加 .env 到 .gitignore 以避免泄漏密钥
grep -q "^\.env" .gitignore 2>/dev/null || echo ".env" >> .gitignore
echo ".env 已添加到 .gitignore"
安全提醒: 密钥仅存储在您当前项目目录中的
.env文件中。切勿将此文件提交到版本控制。.env已自动添加到.gitignore。
步骤 1:在消耗信用点数之前与用户确认计划
关键:在创建任何任务之前,向用户显示成本摘要并等待确认:
我将使用以下方案生成 "<prompt>" 的 3D 模型:
1. 预览(网格生成)— 20 信用点数
2. 精细处理(基于 PBR 的纹理)— 10 信用点数
3. 下载为 .glb
总成本:30 信用点数
当前余额:<N> 信用点数
是否继续?
对于多步骤管线(文本转 3d → 绑定 → 动画),请预先显示完整的管线成本。
注意: 绑定自动包括行走和奔跑动画,无需额外费用。对于超出这些的自定义动画,仅添加
Animate(3 信用点数)。
意图 → API 映射
| 用户想要... | API | 端点 | 信用点数 |
|---|---|---|---|
| 从文本生成 3D 模型 | 文本转 3D | POST /openapi/v2/text-to-3d | 5–20(预览)+ 10(精细处理) |
| 从单张图像生成 3D 模型 | 图像转 3D | POST /openapi/v1/image-to-3d | 5–30 |
| 从多张图像生成 3D 模型 | 多图像转 3D | POST /openapi/v1/multi-image-to-3d | 5–30 |
| 为现有模型添加新纹理 | 重新纹理 | POST /openapi/v1/retexture | 10 |
| 更改网格格式/拓扑 | 重新网格化 | POST /openapi/v1/remesh | 5 |
| 将模型转换为其他格式(无重新网格化) | 转换 | POST /openapi/v1/convert | 1 |
| 将模型缩放到真实世界尺寸 | 调整大小 | POST /openapi/v1/resize | 1 |
| 在外部纹理处理前生成新 UV(GLB,≤4 万面) | UV 展开 | POST /openapi/v1/uv-unwrap | 5 |
| 为角色添加骨架 | 自动绑定 | POST /openapi/v1/rigging | 5 |
| 为已绑定角色添加动画 | 动画 | POST /openapi/v1/animations | 3 |
| 从文本生成 2D 图像(推荐作为图像转 3d 的前置步骤) | 文本转图像 | POST /openapi/v1/text-to-image | 3 / 6 / 9 / 9 |
| 优化/编辑 2D 图像(推荐作为图像转 3d 的前置步骤) | 图像转图像 | POST /openapi/v1/image-to-image | 3 / 6 / 9 / 12 |
| 照片 → 定制实体产品(人偶/灯/钥匙扣/冰箱贴) | 创意工作室 | POST /openapi/creative-lab/{product}/v1/prototype 然后 .../build | 6 + 30 |
| 检查 FDM 可打印性 | 分析可打印性 | POST /openapi/v1/print/analyze | 0(免费) |
| 修复非流形/退化面/孔洞拓扑 | 修复可打印性 | POST /openapi/v1/print/repair | 10 |
| 多色 3D 打印 | 多色打印 | POST /openapi/v1/print/multi-color | 10(+ 生成成本) |
| 3D 打印模型(白色) | → 参见打印管线部分 | — | 20 |
| 检查信用点数余额 | 余额 | GET /openapi/v1/balance | 0 |
步骤 2:执行工作流
可重用脚本模板
将此作为所有工作流的基础。它从环境或当前目录中的 .env 文件安全地加载 API 密钥:
#!/usr/bin/env python3
"""Meshy API 任务运行器。处理创建 → 轮询 → 下载。"""
import requests, time, os, sys, re, json
from datetime import datetime
# --- 安全 API 密钥加载 ---
def load_api_key():
"""从环境变量加载 MESHY_API_KEY,然后从 cwd 中的 .env 加载。"""
key = os.environ.get("MESHY_API_KEY", "").strip()
if key:
return key
env_path = os.path.join(os.getcwd(), ".env")
if os.path.exists(env_path):
with open(env_path) as f:
for line in f:
line = line.strip()
if line.startswith("MESHY_API_KEY=") and not line.startswith("#"):
val = line.split("=", 1)[1].strip().strip('"').strip("'")
if val:
return val
return ""
API_KEY = load_api_key()
if not API_KEY:
sys.exit("错误:MESHY_API_KEY 未设置。运行步骤 0a 进行配置。")
# 永不记录完整密钥 — 仅记录前 8 个字符用于可追溯性
print(f"API 密钥已加载: {API_KEY[:8]}...")
BASE = "https://api.meshy.ai"
HEADERS = {"Authorization": f"Bearer {API_KEY}"}
SESSION = requests.Session()
SESSION.trust_env = False # 绕过系统代理设置
def create_task(endpoint, payload):
resp = SESSION.post(f"{BASE}{endpoint}", headers=HEADERS, json=payload, timeout=30)
if resp.status_code == 401:
sys.exit("错误:API 密钥无效 (401)。重新运行步骤 0a。")
if resp.status_code == 402:
try:
bal = SESSION.get(f"{BASE}/openapi/v1/balance", headers=HEADERS, timeout=10)
balance = bal.json().get("balance", "unknown")
sys.exit(f"错误:信用点数不足 (402)。余额:{balance}。请在 https://www.meshy.ai/pricing 充值")
except Exception:
sys.exit("错误:信用点数不足 (402)。请在 https://www.meshy.ai/pricing 检查余额")
if resp.status_code == 429:
sys.exit("错误:已达到速率限制 (429)。请等待并重试。")
resp.raise_for_status()
task_id = resp.json()["result"]
print(f"TASK_CREATED: {task_id}")
return task_id
def poll_task(endpoint, task_id, timeout=300):
"""使用指数退避进行轮询(5秒→30秒,进度 >= 95% 时固定为 15 秒)。"""
elapsed, delay, max_delay, backoff, finalize_delay, poll_count = 0, 5, 30, 1.5, 15, 0
while elapsed < timeout:
poll_count += 1
resp = SESSION.get(f"{BASE}{endpoint}/{task_id}", headers=HEADERS, timeout=30)
resp.raise_for_status()
task = resp.json()
status = task["status"]
progress = task.get("progress", 0)
bar = f"[{'█' * int(progress/5)}{'░' * (20 - int(progress/5))}] {progress}%"
print(f" {bar} — {status} ({elapsed}s, poll #{poll_count})", flush=True)
if status == "SUCCEEDED":
return task
if status in ("FAILED", "CANCELED"):
msg = task.get("task_error", {}).get("message", "Unknown")
sys.exit(f"TASK_{status}: {msg}")
current_delay = finalize_delay if progress >= 95 else delay
time.sleep(current_delay)
elapsed += current_delay
if progress < 95:
delay = min(delay * backoff, max_delay)
sys.exit(f"超时,已等待 {timeout}s({poll_count} 次轮询)")
def download(url, filepath):
"""将文件下载到项目目录(在 cwd/meshy_output/ 内)。"""
os.makedirs(os.path.dirname(filepath), exist_ok=True)
print(f"正在下载 {filepath}...", flush=True)
resp = SESSION.get(url, timeout=300, stream=True)
resp.raise_for_status()
with open(filepath, "wb") as f:
for chunk in resp.iter_content(chunk_size=8192):
f.write(chunk)
print(f"已下载: {filepath} ({os.path.getsize(filepath)/1024/1024:.1f} MB)")
# --- 文件组织辅助函数 ---
OUTPUT_ROOT = os.path.join(os.getcwd(), "meshy_output")
os.makedirs(OUTPUT_ROOT, exist_ok=True)
HISTORY_FILE = os.path.join(OUTPUT_ROOT, "history.json")
def get_project_dir(task_id, prompt="", task_type="model"):
slug = re.sub(r'[^a-z0-9]+', '-', (prompt or task_type).lower())[:30].strip('-')
folder = f"{datetime.now().strftime('%Y%m%d_%H%M%S')}_{slug}_{task_id[:8]}"
project_dir = os.path.join(OUTPUT_ROOT, folder)
os.makedirs(project_dir, exist_ok=True)
return project_dir
def record_task(project_dir, task_id, task_type, stage, prompt="", files=None):
meta_path = os.path.join(project_dir, "metadata.json")
meta = json.load(open(meta_path)) if os.path.exists(meta_path) else {
"project_name": prompt or task_type, "folder": os.path.basename(project_dir),
"root_task_id": task_id, "created_at": datetime.now().isoformat(), "tasks": []
}
meta["tasks"].append({"task_id": task_id, "task_type": task_type, "stage": stage,
"files": files or [], "created_at": datetime.now().isoformat()})
meta["updated_at"] = datetime.now().isoformat()
json.dump(meta, open(meta_path, "w"), indent=2)
history = json.load(open(HISTORY_FILE)) if os.path.exists(HISTORY_FILE) else {"version": 1, "projects": []}
folder = os.path.basename(project_dir)
entry = next((p for p in history["projects"] if p["folder"] == folder), None)
if entry:
entry.update({"task_count": len(meta["tasks"]), "updated_at": meta["updated_at"]})
else:
history["projects"].append({"folder": folder, "prompt": prompt, "task_type": task_type,
"root_task_id": task_id, "created_at": meta["created_at"],
"updated_at": meta["updated_at"], "task_count": len(meta["tasks"])})
json.dump(history, open(HISTORY_FILE, "w"), indent=2)
def save_thumbnail(project_dir, url):
path = os.path.join(project_dir, "thumbnail.png")
if os.path.exists(path): return
try:
r = SESSION.get(url, timeout=15); r
(脚本模板的其余部分遵循相同的模式。)