"""Designs API endpoints.""" import io import logging from pathlib import Path import httpx from fastapi import APIRouter, HTTPException from fastapi.responses import FileResponse, StreamingResponse from PIL import Image from app.config import get_settings from app.schemas.design import Design from app.services.design_service import DesignService logger = logging.getLogger(__name__) router = APIRouter() design_service = DesignService() settings = get_settings() # Mockup template configs: product_type → (template path, design bounding box, blend mode) # Coordinates are for 1024x1024 photorealistic templates MOCKUP_TEMPLATES = { "shirt": { "template": "shirt-template.png", "design_box": (262, 230, 500, 450), # x, y, w, h — chest area on black tee "blend": "screen", # screen blend for light designs on dark fabric }, "sticker": { "template": "sticker-template.png", "design_box": (270, 210, 470, 530), # inside the white sticker area "blend": "paste", }, "print": { "template": "print-template.png", "design_box": (225, 225, 575, 500), # inside the black frame "blend": "paste", }, } # Map mockup type → matching product types from metadata _TYPE_MAP = { "shirt": ("shirt", "tshirt", "tee", "hoodie"), "sticker": ("sticker",), "print": ("print",), } # Cache generated mockups in memory: (slug, product_type) → PNG bytes _mockup_cache: dict[tuple[str, str], bytes] = {} @router.get("", response_model=list[Design]) async def list_designs( status: str = "active", category: str | None = None, space: str | None = None, ): """List all designs.""" designs = await design_service.list_designs(status=status, category=category, space=space) return designs @router.get("/{slug}", response_model=Design) async def get_design(slug: str): """Get a single design by slug.""" design = await design_service.get_design(slug) if not design: raise HTTPException(status_code=404, detail="Design not found") return design @router.get("/{slug}/image") async def get_design_image(slug: str): """Serve the design image.""" image_path = await design_service.get_design_image_path(slug) if not image_path or not Path(image_path).exists(): raise HTTPException(status_code=404, detail="Image not found") return FileResponse( image_path, media_type="image/png", headers={ "Cache-Control": "public, max-age=86400", }, ) @router.get("/{slug}/mockup") async def get_design_mockup(slug: str, type: str = "shirt", fresh: bool = False): """Serve the design composited onto a product mockup template. For Printful-provider designs: fetches photorealistic mockup from Printful's mockup generator API (cached after first generation). For other designs: composites with Pillow using local templates. Query params: type: Product type — "shirt", "sticker", or "print" (default: shirt) fresh: If true, bypass cache and regenerate mockup """ cache_key = (slug, type) if not fresh and cache_key in _mockup_cache: return StreamingResponse( io.BytesIO(_mockup_cache[cache_key]), media_type="image/png", headers={"Cache-Control": "public, max-age=86400"}, ) # Load design to check provider design = await design_service.get_design(slug) if not design: raise HTTPException(status_code=404, detail="Design not found") # Find a Printful-provider product matching the requested mockup type printful_product = None accepted_types = _TYPE_MAP.get(type, (type,)) for p in design.products: if p.provider == "printful" and p.type in accepted_types: printful_product = p break # Try Printful mockup API for Printful-provider designs if printful_product and settings.printful_api_token: png_bytes = await _get_printful_mockup(slug, printful_product) if png_bytes: _mockup_cache[cache_key] = png_bytes return StreamingResponse( io.BytesIO(png_bytes), media_type="image/png", headers={"Cache-Control": "public, max-age=86400"}, ) # Fallback: Pillow compositing with local templates return await _pillow_mockup(slug, type) async def _get_printful_mockup(slug: str, product) -> bytes | None: """Fetch mockup from Printful API. Returns PNG bytes or None.""" from app.pod.printful_client import PrintfulClient printful = PrintfulClient() if not printful.enabled: return None try: product_id = int(product.sku) # Get first variant for mockup preview variants = await printful.get_catalog_variants(product_id) if not variants: logger.warning(f"No Printful variants for product {product_id}") return None variant_ids = [variants[0]["id"]] # Public image URL for Printful to download image_url = f"{settings.public_url}/api/designs/{slug}/image" # Generate mockup (blocks up to ~60s on first call) mockups = await printful.generate_mockup_and_wait( product_id=product_id, variant_ids=variant_ids, image_url=image_url, placement="front", technique="dtg", ) if not mockups: return None # v2 response: catalog_variant_mockups[] → .mockups[] → .mockup_url mockup_url = None for variant_mockup in mockups: for mockup in variant_mockup.get("mockups", []): mockup_url = mockup.get("mockup_url") or mockup.get("url") if mockup_url: break if mockup_url: break if not mockup_url: logger.warning(f"No mockup URL in Printful response for {slug}") return None # Download the mockup image async with httpx.AsyncClient(timeout=30.0) as client: resp = await client.get(mockup_url) resp.raise_for_status() return resp.content except Exception as e: logger.warning(f"Printful mockup failed for {slug}: {e}") return None async def _pillow_mockup(slug: str, type: str) -> StreamingResponse: """Generate photorealistic mockup using Pillow compositing.""" from PIL import ImageChops template_config = MOCKUP_TEMPLATES.get(type) if not template_config: raise HTTPException(status_code=400, detail=f"Unknown product type: {type}") image_path = await design_service.get_design_image_path(slug) if not image_path or not Path(image_path).exists(): raise HTTPException(status_code=404, detail="Design image not found") # Load template from frontend/public/mockups/ or /app/mockups/ (Docker mount) template_dir = Path(__file__).resolve().parents[3] / "frontend" / "public" / "mockups" template_path = template_dir / template_config["template"] if not template_path.exists(): template_path = Path("/app/mockups") / template_config["template"] if not template_path.exists(): raise HTTPException(status_code=404, detail="Mockup template not found") # Load images template_img = Image.open(str(template_path)).convert("RGB") design_img = Image.open(image_path).convert("RGBA") # Start with the photorealistic template as the base canvas = template_img.copy() # Scale design to fit bounding box while maintaining aspect ratio bx, by, bw, bh = template_config["design_box"] scale = min(bw / design_img.width, bh / design_img.height) dw = int(design_img.width * scale) dh = int(design_img.height * scale) dx = bx + (bw - dw) // 2 dy = by + (bh - dh) // 2 design_resized = design_img.resize((dw, dh), Image.LANCZOS) blend_mode = template_config.get("blend", "paste") if blend_mode == "screen": # Screen blend for light designs on dark fabric. # We use a brightness-based mask so only non-dark pixels from # the design show through, preventing a visible dark rectangle # when the design has its own dark background. design_rgb = design_resized.convert("RGB") # Extract the region under the design region = canvas.crop((dx, dy, dx + dw, dy + dh)) # Screen blend the design onto the fabric region blended = ImageChops.screen(region, design_rgb) # Create a luminance mask from the design — only bright pixels blend in. # This prevents the design's dark background from creating a visible box. lum = design_rgb.convert("L") # Boost contrast so only clearly visible parts of the design show lum = lum.point(lambda p: min(255, int(p * 1.5))) # Composite: use luminance as mask (bright pixels = show blended, dark = keep original) result = Image.composite(blended, region, lum) canvas.paste(result, (dx, dy)) else: # Direct paste — for stickers/prints where design goes on a light surface if design_resized.mode == "RGBA": canvas.paste(design_resized, (dx, dy), design_resized) else: canvas.paste(design_resized, (dx, dy)) # Export to high-quality PNG buf = io.BytesIO() canvas.save(buf, format="PNG", optimize=True) png_bytes = buf.getvalue() # Cache the result cache_key = (slug, type) _mockup_cache[cache_key] = png_bytes return StreamingResponse( io.BytesIO(png_bytes), media_type="image/png", headers={"Cache-Control": "public, max-age=86400"}, )