puppeteer-api / app.py
seungminkwak's picture
reset: clean history (purge leaked token)
08b23ce
import os
import re
import sys
import shutil
import subprocess
from pathlib import Path
from typing import List, Optional
import importlib.util
import requests
from fastapi import FastAPI, HTTPException
from fastapi.responses import FileResponse
from pydantic import BaseModel, HttpUrl, Field
# ----------------------------------------------------------------------------- #
# ๐Ÿ”ง ํ™˜๊ฒฝ ๊ณ ์ •: libgomp ๊ฒฝ๊ณ /์—๋Ÿฌ ํšŒํ”ผ (invalid OMP_NUM_THREADS)
# ----------------------------------------------------------------------------- #
# ์ผ๋ถ€ ์ปจํ…Œ์ด๋„ˆ์—์„œ OMP_NUM_THREADS๊ฐ€ ๋น„์–ด์žˆ๊ฑฐ๋‚˜ ์ž˜๋ชป ๋“ค์–ด๊ฐ€๋ฉด libgomp๊ฐ€ ์—๋Ÿฌ๋ฅผ ๋ƒ…๋‹ˆ๋‹ค.
# ์•ˆ์ „ํ•˜๊ฒŒ ์ •์ˆ˜๊ฐ’์œผ๋กœ ๊ฐ•์ œ ์„ธํŒ…ํ•ฉ๋‹ˆ๋‹ค.
os.environ["OMP_NUM_THREADS"] = os.environ.get("OMP_NUM_THREADS", "4")
if not os.environ["OMP_NUM_THREADS"].isdigit():
os.environ["OMP_NUM_THREADS"] = "4"
# ----------------------------------------------------------------------------- #
# ๐Ÿ”ง ๋Ÿฐํƒ€์ž„ ์˜์กด์„ฑ ์ž๋™ ์„ค์น˜ (tqdm, einops, scipy, trimesh ๋“ฑ)
# - requirements/Dockerfile์— ๋น ์ง„ ๊ฒฝ์šฐ๋ฅผ ๋Œ€๋น„ํ•ด, ์„œ๋ฒ„ ๊ธฐ๋™ ์‹œ ํ•œ ๋ฒˆ ์ฒดํฌํ•ด์„œ ์„ค์น˜
# ----------------------------------------------------------------------------- #
RUNTIME_DEPS = [
"tqdm",
"einops",
"scipy",
"trimesh",
"accelerate", # ์ถ”๊ฐ€
"timm", # ์ถ”๊ฐ€
# ์•„๋ž˜๋Š” ์—ฌ์œ  ํŒจํ‚ค์ง€ (์—๋Ÿฌ ๋‚˜๋ฉด ์ž๋™ ๋ณด๊ฐ•)
"networkx",
"scikit-image",
]
def _need_install(mod_name: str) -> bool:
return importlib.util.find_spec(mod_name) is None
def _pip_install(pkgs: List[str]) -> None:
if not pkgs:
return
try:
subprocess.check_call([sys.executable, "-m", "pip", "install", *pkgs])
except Exception as e:
print(f"[deps] pip install failed for {pkgs}: {e}")
def _ensure_runtime_deps() -> None:
# numpy 2.x๋ฉด scipy ๋“ฑ๊ณผ ์ถฉ๋Œ ๊ฐ€๋Šฅ โ†’ numpy<2๋กœ ๋‚ด๋ฆฌ๋Š” ์‹œ๋„
try:
import numpy as _np
if _np.__version__.startswith("2"):
print(f"[deps] numpy=={_np.__version__} detected; attempting to pin <2.0")
_pip_install(["numpy<2"])
except Exception as e:
print(f"[deps] numpy check failed: {e}")
# ํ•„์ˆ˜ ๋ชจ๋“ˆ ์ฑ„์šฐ๊ธฐ
missing = [m for m in RUNTIME_DEPS if _need_install(m)]
if missing:
print(f"[deps] installing missing modules: {missing}")
_pip_install(missing)
# ์ตœ์ข… ํ™•์ธ ๋กœ๊ทธ
for m in RUNTIME_DEPS:
print(f"[deps] {m} -> {'OK' if not _need_install(m) else 'MISSING'}")
_ensure_runtime_deps()
# ----------------------------------------------------------------------------- #
# FastAPI ์ดˆ๊ธฐํ™”
# ----------------------------------------------------------------------------- #
app = FastAPI(title="Puppeteer API", version="1.0.0")
# ----------------------------------------------------------------------------- #
# Settings
# ----------------------------------------------------------------------------- #
PUPPETEER_SRC = Path(os.environ.get("PUPPETEER_DIR", "/app/Puppeteer")) # ์ฝ๊ธฐ ์ „์šฉ ์›๋ณธ
PUPPETEER_RUN = Path(os.environ.get("PUPPETEER_RUN", "/tmp/puppeteer_run")) # ์‹คํ–‰์šฉ ๋ณต์‚ฌ๋ณธ(์“ฐ๊ธฐ ๊ฐ€๋Šฅ)
RESULT_DIR = Path(os.environ.get("RESULT_DIR", str(PUPPETEER_RUN / "results"))) # rig ๊ฒฐ๊ณผ ๊ธฐ๋ณธ ๊ฒฝ๋กœ
TMP_IN_DIR = Path(os.environ.get("TMP_IN_DIR", "/tmp/in")) # ์ž…๋ ฅ ์ €์žฅ ๊ฒฝ๋กœ
DOWNLOAD_TIMEOUT = int(os.environ.get("DOWNLOAD_TIMEOUT", "180"))
MAX_DOWNLOAD_MB = int(os.environ.get("MAX_DOWNLOAD_MB", "512"))
SAFE_NAME = re.compile(r"[^A-Za-z0-9._-]+")
# ์• ๋‹ˆ๋ฉ”์ด์…˜/๋ฆฌ๊น… ๊ฒฐ๊ณผ๋ฅผ ํญ๋„“๊ฒŒ ์ฐพ๊ธฐ ์œ„ํ•œ ํ›„๋ณด ๊ฒฝ๋กœ
RESULT_BASES = [
Path("/app/Puppeteer/results"),
RESULT_DIR,
Path("/data/results"),
Path("/tmp/puppeteer_run/results"),
]
# ----------------------------------------------------------------------------- #
# Auto-download checkpoints (๋Ÿฐํƒ€์ž„ ์‹œ ์ž๋™ ๋‹ค์šด๋กœ๋“œ)
# ----------------------------------------------------------------------------- #
ckpt_path = Path("/app/Puppeteer/checkpoints")
if not ckpt_path.exists() or not any(ckpt_path.iterdir()):
try:
print("[init] checkpoints missing โ€” trying runtime download via script...")
subprocess.run(
["bash", "-lc", "cd /app/Puppeteer && ./scripts/download_ckpt.sh"],
check=True,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
)
print("[init] Puppeteer checkpoints downloaded successfully via script.")
except Exception as e:
print("[init] WARNING: checkpoint script failed:", e)
try:
ckpt_path.mkdir(parents=True, exist_ok=True)
print("[init] trying manual download from GitHub release...")
subprocess.run(
[
"wget",
"-O",
"/app/Puppeteer/checkpoints/rig.ckpt",
"https://github.com/ByteDance-Seed/Puppeteer/releases/download/v1.0.0/rig.ckpt",
],
check=True,
)
print("[init] rig.ckpt downloaded manually.")
except Exception as e2:
print("[init] WARNING: manual checkpoint download failed:", e2)
# ----------------------------------------------------------------------------- #
# Schemas
# ----------------------------------------------------------------------------- #
class RigIn(BaseModel):
mesh_url: HttpUrl = Field(..., description="Input mesh URL (obj/glb/fbx/โ€ฆ)")
workdir: Optional[str] = Field(default=None, description="Optional work directory name")
class RigOut(BaseModel):
status: str
result_dir: Optional[str] = None
files_preview: Optional[List[str]] = None
detail: Optional[str] = None
gpu: Optional[bool] = None
gpu_name: Optional[str] = None
class AnimateIn(BaseModel):
video_url: HttpUrl = Field(..., description="Input video URL (mp4, mov, etc.)")
mesh_path: Optional[str] = Field(
default="/app/Puppeteer/results/rigged.glb",
description="Path to rigged mesh"
)
# ----------------------------------------------------------------------------- #
# Utils
# ----------------------------------------------------------------------------- #
def ensure_dirs() -> None:
TMP_IN_DIR.mkdir(parents=True, exist_ok=True)
PUPPETEER_RUN.mkdir(parents=True, exist_ok=True)
RESULT_DIR.mkdir(parents=True, exist_ok=True)
def prepare_run_tree() -> None:
if not PUPPETEER_SRC.exists():
raise HTTPException(status_code=500, detail=f"Puppeteer not found: {PUPPETEER_SRC}")
shutil.copytree(PUPPETEER_SRC, PUPPETEER_RUN, dirs_exist_ok=True)
script = PUPPETEER_RUN / "demo_rigging.sh"
if script.exists():
script.chmod(0o755)
def safe_basename(url: str) -> str:
name = os.path.basename(url.split("?")[0])
return SAFE_NAME.sub("_", name) or "input_mesh"
def download_with_limit(url: str, dst: Path) -> None:
with requests.get(url, stream=True, timeout=DOWNLOAD_TIMEOUT) as r:
r.raise_for_status()
total = 0
with open(dst, "wb") as f:
for chunk in r.iter_content(chunk_size=1024 * 1024):
if not chunk:
continue
total += len(chunk)
if total > MAX_DOWNLOAD_MB * 1024 * 1024:
raise HTTPException(status_code=413, detail="File too large")
f.write(chunk)
def torch_info() -> tuple[bool, Optional[str]]:
try:
import torch
ok = torch.cuda.is_available()
name = torch.cuda.get_device_name(0) if ok else None
return ok, name
except Exception:
return False, None
def scan_results(limit: int = 200) -> List[str]:
files: List[str] = []
exts = ("*.glb", "*.mp4", "*.fbx", "*.obj", "*.gltf", "*.png", "*.jpg", "*.json", "*.txt")
for base in RESULT_BASES:
if base.exists():
for ext in exts:
for p in base.rglob(ext):
if p.is_file():
files.append(str(p))
if len(files) >= limit:
return files
return files
# ----------------------------------------------------------------------------- #
# Routes
# ----------------------------------------------------------------------------- #
@app.get("/")
def root():
return {"status": "ready", "service": "puppeteer-api"}
@app.get("/health")
def health():
gpu, name = torch_info()
return {"status": "ok", "cuda": gpu, "gpu": name}
@app.post("/rig", response_model=RigOut)
def rig(inp: RigIn):
ensure_dirs()
prepare_run_tree()
basename = safe_basename(str(inp.mesh_url))
mesh_path = TMP_IN_DIR / basename
_ = SAFE_NAME.sub("_", inp.workdir or "job") # reserved, ํ˜„์žฌ๋Š” ๋ฏธ์‚ฌ์šฉ
try:
download_with_limit(str(inp.mesh_url), mesh_path)
except Exception as e:
raise HTTPException(status_code=400, detail=f"Download error: {e}")
script = PUPPETEER_RUN / "demo_rigging.sh"
cmd = ["bash", str(script), str(mesh_path)]
try:
proc = subprocess.run(
cmd,
cwd=str(PUPPETEER_RUN),
check=True,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
)
run_log = proc.stdout[-4000:]
except subprocess.CalledProcessError as e:
snippet = (e.stdout or "")[-2000:]
raise HTTPException(status_code=500, detail=f"Puppeteer failed: {snippet}")
except FileNotFoundError:
raise HTTPException(status_code=500, detail="demo_rigging.sh not found")
preview = scan_results(limit=20)
gpu, gpu_name = torch_info()
return RigOut(
status="ok",
result_dir=str(RESULT_DIR),
files_preview=preview[:10],
detail=run_log if preview else "no result files found",
gpu=gpu,
gpu_name=gpu_name,
)
@app.post("/animate")
def animate(inp: AnimateIn):
"""
Puppeteer์˜ demo_animation.sh ์‹คํ–‰ (์˜์ƒ ๊ธฐ๋ฐ˜ ์• ๋‹ˆ๋ฉ”์ด์…˜)
์ž…๋ ฅ: video_url (mp4), mesh_path (rigged.glb ๊ธฐ๋ณธ๊ฐ’)
"""
pdir = Path("/app/Puppeteer")
script = pdir / "demo_animation.sh"
video_path = Path("/tmp/video.mp4")
if not script.exists():
raise HTTPException(status_code=404, detail="demo_animation.sh not found")
# -------- requests ๊ธฐ๋ฐ˜ ์˜์ƒ ๋‹ค์šด๋กœ๋“œ -------- #
try:
print(f"[animate] downloading video from {inp.video_url}")
with requests.get(str(inp.video_url), stream=True, timeout=60) as r:
r.raise_for_status()
with open(video_path, "wb") as f:
for chunk in r.iter_content(chunk_size=8192):
if chunk:
f.write(chunk)
print(f"[animate] Video saved to {video_path}")
except Exception as e:
raise HTTPException(status_code=400, detail=f"Video download failed via requests: {e}")
# -------- Puppeteer ์• ๋‹ˆ๋ฉ”์ด์…˜ ์‹คํ–‰ -------- #
cmd = [
"bash", str(script),
"--mesh", str(inp.mesh_path),
"--video", str(video_path),
]
try:
proc = subprocess.run(
cmd,
cwd=str(pdir),
check=True,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
)
output = proc.stdout[-4000:]
except subprocess.CalledProcessError as e:
raise HTTPException(status_code=500, detail=f"Animation failed: {e.stdout[-2000:]}")
except Exception as e:
raise HTTPException(status_code=500, detail=f"Unexpected error: {e}")
anim_results = scan_results(limit=20)
return {
"status": "ok",
"video_used": str(inp.video_url),
"detail": output,
"files_preview": anim_results[:10],
}
# -------- ๊ฒฐ๊ณผ ํŒŒ์ผ ํ™•์ธ/๋‹ค์šด๋กœ๋“œ ์œ ํ‹ธ -------- #
@app.get("/list")
def list_results():
files = scan_results(limit=500)
return {"count": len(files), "files": files}
@app.get("/download")
def download(path: str):
p = Path(path).resolve()
# ์•ˆ์ „ํ•œ ๊ฒฝ๋กœ๋งŒ ํ—ˆ์šฉ
if not any(str(p).startswith(str(b.resolve())) for b in RESULT_BASES):
raise HTTPException(status_code=400, detail="invalid path")
if not p.exists() or not p.is_file():
raise HTTPException(status_code=404, detail="file not found")
return FileResponse(str(p), filename=p.name)
@app.get("/debug")
def debug():
pdir = Path("/app/Puppeteer")
script = pdir / "demo_rigging.sh"
ckpt_dir = pdir / "checkpoints"
req_file = pdir / "requirements.txt"
return {
"script_exists": script.exists(),
"ckpt_dir_exists": ckpt_dir.exists(),
"req_exists": req_file.exists(),
"ckpt_samples": [str(p) for p in ckpt_dir.glob("**/*")][:15],
"tmp_in": os.environ.get("TMP_IN_DIR", "/data/in"),
"result_dir": os.environ.get("RESULT_DIR", "/data/results"),
"omp_num_threads": os.environ.get("OMP_NUM_THREADS"),
}