Communitygithub.com

Meshy 3D — 생성 + 출력

Meshy AI 3D 생성 플랫폼의 AI 에이전트 스킬입니다. API 키 설정, 작업 생성, 폴링, 다운로드, 다단계 파이프라인, 3D 프린트 준비까지 전체 라이프사이클을 다룹니다.

Meshy 3D — 생성 + 출력란 무엇인가요?

이 스킬은 AI 에이전트가 Meshy.ai API와 직접 상호 작용하여 3D 자산을 생성, 다운로드, 3D 프린트 준비까지 수행할 수 있게 해줍니다. 텍스트/이미지에서 3D 모델 생성, 리텍스처, 리메시, UV 언랩, 리깅, 애니메이션, 3D 프린트 가능성 분석 및 수리 등 다양한 워크플로를 지원하며, 모든 작업은 하나의 Python 스크립트에서 생성 → 상태 폴링 → 다운로드의 전체 흐름으로 실행됩니다. API 키는 환경 변수나 현재 작업 디렉토리의 `.env` 파일에서 안전하게 로드하며, 생성된 파일은 `meshy_output/` 디렉토리 아래에 프로젝트별 폴더로 체계적으로 정리되어 `metadata.json`과 `history.json`으로 추적됩니다. 크레딧을 소모하기 전에 사용자에게 비용 요약을 제시하고 확인을 받는 안전 장치가 내장되어 있어 불필요한 크레딧 소모를 방지합니다.

지원 대상~Claude Code~Codex CLI~Cursor
npx skills add meshy-dev/meshy-3d-agent

즐겨 사용하는 AI에게 물어보기

이 에이전트 스킬이 미리 로드된 새 채팅을 엽니다.

문서

Meshy 3D — 생성 + 출력은(는) 무엇을 하나요?

Meshy AI API와 직접 통신하여 3D 자산을 생성하고 출력합니다. API 키 설정, 작업 생성, 지수 백오프 폴링, 다운로드, 다단계 파이프라인, 슬라이서 통합을 통한 3D 프린트 준비까지 전체 라이프사이클을 다룹니다.


보안 선언

액세스하는 환경 변수:

  • MESHY_API_KEY — HTTP Authorization: Bearer 헤더로 전송되는 API 인증 토큰입니다. 절대 기록되지 않으며, 사용자가 명시적으로 요청한 경우에만 현재 작업 디렉토리의 .env 파일에 기록됩니다.

외부 네트워크 엔드포인트:

  • https://api.meshy.ai — Meshy AI API (작업 생성, 상태 폴링, 모델/이미지 다운로드)

파일 시스템 액세스:

  • 읽기: 현재 작업 디렉토리의 .env 파일 (API 키 조회)
  • 쓰기: 현재 작업 디렉토리의 .env 파일 (API 키 저장, 사용자 요청 시에만)
  • 쓰기: 현재 작업 디렉토리의 ./meshy_output/ (다운로드된 모델 파일, 메타데이터)
  • 읽기: 사용자가 명시적으로 제공한 파일 (예: 이미지-3D 변환에 사용되는 로컬 이미지), 사용자가 지정한 정확한 경로에서만 액세스
  • 홈 디렉토리, 셸 프로필 또는 위 범위를 벗어난 경로에는 액세스하지 않음

이 머신을 떠나는 데이터:

  • 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에 작업을 추적합니다.
  • 모델과 함께 썸네일을 자동 다운로드합니다.

중요: 셸 명령 규칙

표준 POSIX 도구만 사용하세요. rg, fd, bat, exa/eza를 사용하지 마세요.


중요: 장기 실행 작업을 올바르게 수행

Meshy 생성에는 15분이 소요됩니다. 생성 → 폴링 → 다운로드 전체 흐름을 하나의 Python 스크립트로 작성하여 단일 Bash 호출로 실행하세요. 버퍼링되지 않은 출력을 위해 python3 -u script.py를 사용하세요. 99%에서 30120초 동안 대기하는 것은 정상적인 최종화 과정이므로 중단하지 마세요.


0단계: API 키 감지 (항상 먼저 실행)

현재 세션 환경과 현재 작업 디렉토리의 .env 파일만 확인하세요. 홈 디렉토리나 셸 프로필 파일을 검색하지 마세요.

echo "=== Meshy API Key Detection ==="

# 1. 현재 환경 변수 확인
if [ -n "$MESHY_API_KEY" ]; then
  echo "ENV_VAR: FOUND (${MESHY_API_KEY:0:8}...)"
else
  echo "ENV_VAR: NOT_FOUND"
fi

# 2. 현재 작업 디렉토리의 .env 파일만 확인
if [ -f ".env" ] && grep -q "MESHY_API_KEY" ".env" 2>/dev/null; then
  echo "DOTENV(.env): FOUND"
  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 "READY: key=${MESHY_API_KEY:0:8}..."
else
  echo "READY: NO_KEY_FOUND"
fi

# 4. Python requests 확인
python3 -c "import requests; print('PYTHON_REQUESTS: OK')" 2>/dev/null || echo "PYTHON_REQUESTS: MISSING (run: pip install requests)"

echo "=== Detection Complete ==="

감지 후 결정

  • 키가 발견됨 → 1단계로 진행합니다.
  • 키가 발견되지 않음 → 0a단계로 이동합니다.
  • Python requests 누락됨 → pip install requests를 실행합니다.

0a단계: API 키 설정 (키를 찾을 수 없는 경우에만)

사용자에게 알립니다:

Meshy API를 사용하려면 API 키가 필요합니다:

  1. **https://www.meshy.ai/settings/api**로 이동합니다.
  2. "Create API Key" 를 클릭하고 이름을 지정한 후 키(msy_로 시작)를 복사합니다.
  3. 키는 한 번만 표시되므로 안전한 곳에 저장하세요.

참고: 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 "Key valid. $BALANCE"
else
  echo "Key invalid (HTTP $STATUS). Please check the key and try again."
fi

키를 유지하려면 (현재 프로젝트만):

# 현재 작업 디렉토리의 .env에 쓰기
echo 'MESHY_API_KEY=msy_PASTE_KEY_HERE' >> .env
echo "Saved to .env"

# 중요: 키 유출을 방지하기 위해 .env를 .gitignore에 추가
grep -q "^\.env" .gitignore 2>/dev/null || echo ".env" >> .gitignore
echo ".env added to .gitignore"

보안 알림: 키는 현재 프로젝트 디렉토리의 .env에만 저장됩니다. 이 파일을 버전 관리에 커밋하지 마십시오. .env가 .gitignore에 자동으로 추가되었습니다.


1단계: 크레딧을 소비하기 전에 사용자와 계획 확인

중요: 작업을 생성하기 전에 사용자에게 비용 요약을 제시하고 확인을 기다리세요:

"<prompt>"에 대한 3D 모델을 다음 계획으로 생성하겠습니다:

  1. 미리보기 (메시 생성) — 20 크레딧
  2. 리파인 (PBR 텍스처링) — 10 크레딧
  3. .glb로 다운로드

  총 비용: 30 크레딧
  현재 잔액: <N> 크레딧

  진행할까요?

다단계 파이프라인(텍스트-3D → 리깅 → 애니메이션)의 경우 전체 파이프라인 비용을 미리 보여주세요.

참고: 리깅에는 걷기 + 달리기 애니메이션이 추가 비용 없이 자동으로 포함됩니다. 그 외의 사용자 정의 애니메이션이 필요한 경우에만 애니메이션(3 크레딧)을 추가하세요.

의도 → API 매핑

사용자가 원하는 것API엔드포인트크레딧
텍스트로 3D 모델Text to 3DPOST /openapi/v2/text-to-3d5–20 (미리보기) + 10 (리파인)
단일 이미지로 3D 모델Image to 3DPOST /openapi/v1/image-to-3d5–30
여러 이미지로 3D 모델Multi-Image to 3DPOST /openapi/v1/multi-image-to-3d5–30
기존 모델에 새 텍스처RetexturePOST /openapi/v1/retexture10
메시 형식/토폴로지 변경RemeshPOST /openapi/v1/remesh5
모델을 다른 형식으로 변환 (리메시 없음)ConvertPOST /openapi/v1/convert1
모델을 실제 크기로 재조정ResizePOST /openapi/v1/resize1
외부 텍스처링 전에 새 UV 생성 (GLB, ≤40k 면)UV UnwrapPOST /openapi/v1/uv-unwrap5
캐릭터에 골격 추가Auto-RiggingPOST /openapi/v1/rigging5
리깅된 캐릭터 애니메이션AnimationPOST /openapi/v1/animations3
텍스트로 2D 이미지 생성 (이미지-3D 전 권장 단계)Text to ImagePOST /openapi/v1/text-to-image3 / 6 / 9 / 9
2D 이미지 최적화/편집 (이미지-3D 전 권장 단계)Image to ImagePOST /openapi/v1/image-to-image3 / 6 / 9 / 12
사진 → 스타일링된 물리적 제품 (피규어/램프/키체인/냉장고자석)Creative LabPOST /openapi/creative-lab/{product}/v1/prototype 다음 .../build6 + 30
FDM 출력 가능성 확인Analyze PrintabilityPOST /openapi/v1/print/analyze0 (무료)
비매니폴드/변형 면/구멍 토폴로지 수리Repair PrintabilityPOST /openapi/v1/print/repair10
다색 3D 출력Multi-Color PrintPOST /openapi/v1/print/multi-color10 (+ 생성)
모델 3D 출력 (흰색)→ 출력 파이프라인 섹션 참조—20
크레딧 잔액 확인BalanceGET /openapi/v1/balance0

2단계: 워크플로 실행

재사용 가능한 스크립트 템플릿

이 템플릿을 모든 워크플로의 기반으로 사용하세요. 환경 또는 현재 디렉토리의 .env에서만 API 키를 안전하게 로드합니다:

#!/usr/bin/env python3
"""Meshy API task runner. Handles create → poll → download."""
import requests, time, os, sys, re, json
from datetime import datetime

# --- Secure API key loading ---
def load_api_key():
    """Load MESHY_API_KEY from environment, then .env in cwd only."""
    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("ERROR: MESHY_API_KEY not set. Run Step 0a to configure it.")

# Never log the full key — only first 8 chars for traceability
print(f"API key loaded: {API_KEY[:8]}...")

BASE = "https://api.meshy.ai"
HEADERS = {"Authorization": f"Bearer {API_KEY}"}
SESSION = requests.Session()
SESSION.trust_env = False  # bypass any system proxy settings

def create_task(endpoint, payload):
    resp = SESSION.post(f"{BASE}{endpoint}", headers=HEADERS, json=payload, timeout=30)
    if resp.status_code == 401:
        sys.exit("ERROR: Invalid API key (401). Re-run Step 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"ERROR: Insufficient credits (402). Balance: {balance}. Top up at https://www.meshy.ai/pricing")
        except Exception:
            sys.exit("ERROR: Insufficient credits (402). Check balance at https://www.meshy.ai/pricing")
    if resp.status_code == 429:
        sys.exit("ERROR: Rate limited (429). Wait and retry.")
    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):
    """Poll with exponential backoff (5s→30s, fixed 15s at 95%+)."""
    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 after {timeout}s ({poll_count} polls)")

def download(url, filepath):
    """Download a file into a project directory (within cwd/meshy_output/)."""
    os.makedirs(os.path.dirname(filepath), exist_ok=True)
    print(f"Downloading {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"DOWNLOADED: {filepath} ({os.path.getsize(filepath)/1024/1024:.1f} MB)")

# --- File organization helpers ---
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

관련 스킬