clodes / backend /main.py
nimzuk's picture
CI: sync from GitHub
47d74db verified
import os, uuid, shutil
from pathlib import Path
from typing import Dict, Any
from fastapi import FastAPI, UploadFile, File, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
from PIL import Image, ImageChops
CWD = Path(__file__).resolve().parent.parent
ASSETS_DIR = Path(os.getenv("ASSETS_DIR", CWD / "mock_assets"))
TMP_DIR = Path(os.getenv("TMP_DIR", CWD / "tmp"))
FRONT_DIR_ENV = os.getenv("FRONT_DIR", str(CWD / "frontend"))
for d in (TMP_DIR / "uploads", TMP_DIR / "previews", TMP_DIR / "orders"):
d.mkdir(parents=True, exist_ok=True)
UPLOADS_DIR = TMP_DIR / "uploads"
PREVIEWS_DIR = TMP_DIR / "previews"
ORDERS_DIR = TMP_DIR / "orders"
app = FastAPI(title="Clodesigner API (restored)")
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
class PreviewInput(BaseModel):
model: str = "MT"
view: str = "front"
src: str | None = None
tile: bool = False
offset_x: int = 0
offset_y: int = 0
scale: float = 1.0
details: Dict[str, Any] | None = None
class OrderInput(PreviewInput):
details: Dict[str, Any] | None = None
def _ensure_rgba(img: Image.Image) -> Image.Image:
return img.convert("RGBA") if img.mode != "RGBA" else img
def _load_view_assets(model: str, view: str):
req = ["background.png", "mask.png", "overlay.png"]
opt = ["mask_s1.png", "mask_s2.png"]
candidates = [ASSETS_DIR / model / view, ASSETS_DIR / view]
base = next((p for p in candidates if p.exists()), None)
if not base:
tried = " | ".join(map(str, candidates))
raise HTTPException(400, f"Assets dir not found for view='{view}'. Tried: {tried}")
missing = [n for n in req if not (base / n).exists()]
if missing:
raise HTTPException(400, f"Missing assets in {base}: {', '.join(missing)}")
files = {n: base/n for n in req}
for n in opt:
p = base / n
if p.exists(): files[n] = p
return files
def _resolve_print_config(p: PreviewInput):
details = dict(p.details or {})
src = details.pop("print_path", None) or details.pop("src", None) or p.src
tile = details.pop("tile", None)
offset_x = details.pop("offset_x", None)
offset_y = details.pop("offset_y", None)
scale = details.pop("scale", None)
extras = {}
extra_payload = details.pop("extras", None)
if isinstance(extra_payload, dict):
extras.update(extra_payload)
extras.update(details)
if src is None:
raise HTTPException(400, "No source image specified")
try:
tile_flag = bool(tile if tile is not None else p.tile)
except Exception:
tile_flag = bool(p.tile)
def _to_int(value, default):
if value is None:
return int(default)
try:
return int(value)
except Exception:
raise HTTPException(400, f"Invalid integer value: {value!r}")
def _to_float(value, default):
if value is None:
return float(default)
try:
return float(value)
except Exception:
raise HTTPException(400, f"Invalid float value: {value!r}")
resolved = {
"src": src,
"tile": tile_flag,
"offset_x": _to_int(offset_x, p.offset_x),
"offset_y": _to_int(offset_y, p.offset_y),
"scale": _to_float(scale, p.scale),
"extras": extras,
}
return resolved
def _compose_preview(
src_img_path: Path,
assets,
*,
scale: float = 1.0,
offset_x: int = 0,
offset_y: int = 0,
tile: bool = False,
extras: Dict[str, Any] | None = None,
):
bg = _ensure_rgba(Image.open(assets["background.png"]))
canvas = Image.new("RGBA", bg.size, (0,0,0,0))
canvas.alpha_composite(bg)
extras = dict(extras or {})
base_mask = Image.open(assets["mask.png"]).convert("L")
mask_bbox = extras.pop("mask_bbox", None)
if mask_bbox is not None:
mask_bbox = tuple(int(v) for v in mask_bbox)
else:
mask_bbox = base_mask.getbbox() or (0, 0, canvas.width, canvas.height)
limit_to_mask = bool(extras.pop("limit_to_mask", True))
src_img = _ensure_rgba(Image.open(src_img_path))
area_w = mask_bbox[2] - mask_bbox[0]
area_h = mask_bbox[3] - mask_bbox[1]
target_w = max(1, int(round(area_w * float(scale or 1.0))))
target_h = max(1, int(round(area_h * float(scale or 1.0))))
user = src_img.resize((target_w, target_h), Image.BICUBIC)
user_layer = Image.new("RGBA", canvas.size, (0, 0, 0, 0))
if tile:
step_x = extras.pop("tile_step_x", None)
step_y = extras.pop("tile_step_y", None)
if step_x is None:
step_x = offset_x if offset_x > 0 else user.width
if step_y is None:
step_y = offset_y if offset_y > 0 else user.height
step_x = max(1, int(round(step_x)))
step_y = max(1, int(round(step_y)))
shift_x = extras.pop("tile_shift_x", None)
shift_y = extras.pop("tile_shift_y", None)
if shift_x is None:
shift_x = offset_x if offset_x < 0 else 0
if shift_y is None:
shift_y = offset_y if offset_y < 0 else 0
start_x = mask_bbox[0] + int(shift_x)
start_y = mask_bbox[1] + int(shift_y)
while start_x + user.width < mask_bbox[0]:
start_x += step_x
while start_y + user.height < mask_bbox[1]:
start_y += step_y
while start_x > mask_bbox[0]:
start_x -= step_x
while start_y > mask_bbox[1]:
start_y -= step_y
max_x = mask_bbox[2] + step_x + user.width
max_y = mask_bbox[3] + step_y + user.height
for ty in range(start_y, max_y, step_y):
for tx in range(start_x, max_x, step_x):
user_layer.paste(user, (tx, ty), user)
else:
pos_x = mask_bbox[0] + (area_w - user.width) // 2 + int(offset_x)
pos_y = mask_bbox[1] + (area_h - user.height) // 2 + int(offset_y)
user_layer.paste(user, (pos_x, pos_y), user)
user_alpha = user_layer.split()[-1] if user_layer.getbands()[-1] == "A" else None
if limit_to_mask:
final_mask = base_mask
if user_alpha is not None:
final_mask = ImageChops.multiply(final_mask, user_alpha)
else:
final_mask = user_alpha
if final_mask is not None:
canvas.paste(user_layer, (0, 0), final_mask)
else:
canvas.alpha_composite(user_layer)
for s in ("mask_s1.png","mask_s2.png"):
if s in assets:
m = Image.open(assets[s]).convert("L")
extra_layer = Image.new("RGBA", canvas.size, (0,0,0,0))
extra_layer.alpha_composite(user_layer)
if limit_to_mask and user_alpha is not None:
mask_to_use = ImageChops.multiply(m, user_alpha)
elif limit_to_mask:
mask_to_use = m
else:
mask_to_use = user_alpha or m
canvas.paste(extra_layer, (0,0), mask_to_use)
overlay = _ensure_rgba(Image.open(assets["overlay.png"]))
canvas.alpha_composite(overlay)
return canvas
@app.post("/api/upload")
async def upload(file: UploadFile = File(...)):
name = file.filename or "upload.bin"
data = await file.read()
if not name.lower().endswith((".png",".jpg",".jpeg")):
raise HTTPException(400, "Only PNG/JPG allowed")
if len(data) > 50*1024*1024:
raise HTTPException(400, "File too large (>50MB)")
up = UPLOADS_DIR / f"{uuid.uuid4().hex}_{name}"
up.write_bytes(data)
rel = up.relative_to(CWD)
return {"path": str(rel).replace("\\","/"), "url": f"/files/{rel}".replace("\\","/")}
@app.post("/api/preview")
async def preview(p: PreviewInput):
cfg = _resolve_print_config(p)
src = Path(cfg["src"])
if not src.is_absolute():
src = (CWD / src).resolve()
if not src.exists():
raise HTTPException(400, f"Uploaded file not found: {src}")
img = _compose_preview(
src,
_load_view_assets(p.model, p.view),
scale=cfg["scale"],
offset_x=cfg["offset_x"],
offset_y=cfg["offset_y"],
tile=cfg["tile"],
extras=cfg["extras"],
)
out = PREVIEWS_DIR / f"{uuid.uuid4().hex[:8]}_{p.model}_{p.view}.png"
img.save(out, "PNG")
rel = out.relative_to(CWD)
return {"ok": True, "url": f"/files/{rel}".replace("\\","/")}
@app.post("/api/order")
async def order(p: OrderInput):
cfg = _resolve_print_config(p)
src = Path(cfg["src"])
if not src.is_absolute():
src = (CWD / src).resolve()
if not src.exists():
raise HTTPException(400, f"Uploaded file not found: {src}")
oid = uuid.uuid4().hex[:8]
base = (ORDERS_DIR / oid); (base/"mockups").mkdir(parents=True, exist_ok=True); (base/"sources").mkdir(parents=True, exist_ok=True)
shutil.copy2(src, base/"sources"/Path(src.name))
img = _compose_preview(
src,
_load_view_assets(p.model, p.view),
scale=cfg["scale"],
offset_x=cfg["offset_x"],
offset_y=cfg["offset_y"],
tile=cfg["tile"],
extras=cfg["extras"],
)
out = base/"mockups"/f"{p.model}_{p.view}.png"; img.save(out, "PNG")
return {"ok": True, "order": oid,
"mockups": [f"/files/{out.relative_to(CWD)}".replace("\\","/")],
"mockups_dir": f"/files/{(base/'mockups').relative_to(CWD)}".replace("\\","/")}
front_dir = Path(FRONT_DIR_ENV)
front_dir.mkdir(parents=True, exist_ok=True)
app.mount("/files", StaticFiles(directory=str(CWD)), name="files")
app.mount("/", StaticFiles(directory=str(front_dir), html=True), name="app")