|
|
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") |
|
|
|