/** * Mockup compositor — generates product mockups using Sharp. * * Supports shirt, sticker, poster templates with design overlay. * Falls back to local compositing when Printful API is unavailable. */ import sharp from "sharp"; import { PrintfulClient } from "./pod/printful"; import type { PodMockup } from "./pod/types"; // ── Template definitions ── interface MockupTemplate { /** Width/height of the final mockup image */ width: number; height: number; /** Where the design is placed: [x, y, w, h] */ designBox: [number, number, number, number]; /** Blend mode for compositing */ blend: "over" | "screen"; /** Background color (used when no template image) */ bgColor: string; } const TEMPLATES: Record = { shirt: { width: 1024, height: 1024, designBox: [262, 230, 500, 450], blend: "screen", bgColor: "#1a1a1a", }, sticker: { width: 1024, height: 1024, designBox: [270, 210, 470, 530], blend: "over", bgColor: "#1e293b", }, print: { width: 1024, height: 1024, designBox: [225, 225, 575, 500], blend: "over", bgColor: "#f8fafc", }, poster: { width: 1024, height: 1024, designBox: [225, 225, 575, 500], blend: "over", bgColor: "#f8fafc", }, }; // ── Mockup cache ── const mockupCache = new Map(); const MAX_CACHE = 100; function evictCache() { if (mockupCache.size <= MAX_CACHE) return; const iter = mockupCache.keys(); while (mockupCache.size > MAX_CACHE) { const next = iter.next(); if (next.done) break; mockupCache.delete(next.value); } } // ── Local compositing (Sharp-based fallback) ── /** * Generate a product mockup by compositing the design onto a generated template. * Uses SVG-based template backgrounds for a clean look. */ async function localComposite( designBuffer: Buffer, type: string, ): Promise { const template = TEMPLATES[type] || TEMPLATES.shirt; const [dx, dy, dw, dh] = template.designBox; // Resize design to fit the design box const resizedDesign = await sharp(designBuffer) .resize(dw, dh, { fit: "contain", background: { r: 0, g: 0, b: 0, alpha: 0 } }) .png() .toBuffer(); // Create the base mockup image let base: sharp.Sharp; if (type === "shirt") { // Generate a shirt silhouette SVG base = sharp(Buffer.from(generateShirtSvg(template.width, template.height))) .resize(template.width, template.height); } else if (type === "sticker") { base = sharp(Buffer.from(generateStickerSvg(template.width, template.height))) .resize(template.width, template.height); } else { // Poster/print: simple background base = sharp({ create: { width: template.width, height: template.height, channels: 4, background: template.bgColor, }, }).png(); } // Composite design onto template const result = await base .composite([ { input: resizedDesign, top: dy, left: dx, blend: template.blend === "screen" ? "screen" : "over", }, ]) .png() .toBuffer(); return result; } function generateShirtSvg(w: number, h: number): string { return ` `; } function generateStickerSvg(w: number, h: number): string { return ` `; } // ── Printful API mockup ── async function printfulMockup( designImageUrl: string, printfulSku: number, variantIds?: number[], ): Promise { const client = new PrintfulClient(); if (!client.enabled) return null; try { const vids = variantIds || [await getDefaultVariant(client, printfulSku)].filter(Boolean) as number[]; if (!vids.length) return null; const technique = printfulSku === 358 ? "sublimation" : "dtg"; const mockups = await client.generateMockupAndWait( printfulSku, vids, designImageUrl, "front", technique, 15, 3000, ); if (!mockups?.length) return null; const mockupUrl = mockups[0].mockupUrl; if (!mockupUrl) return null; // Download the mockup image const resp = await fetch(mockupUrl); if (!resp.ok) return null; return Buffer.from(await resp.arrayBuffer()); } catch (err) { console.warn("[rSwag] Printful mockup failed, using local fallback:", err); return null; } } async function getDefaultVariant(client: PrintfulClient, productId: number): Promise { try { const variants = await client.getCatalogVariants(productId); // Pick first black/M variant as default const preferred = variants.find(v => v.size === "M" && v.color.toLowerCase().includes("black")); return preferred?.id || variants[0]?.id || null; } catch { return null; } } // ── Public API ── /** * Generate a product mockup for a design. * * Tries Printful API first (if available and product has Printful SKU), * then falls back to local Sharp compositing. */ export async function generateMockup( designBuffer: Buffer, mockupType: string, options?: { /** If provided, attempts Printful API mockup first */ printfulSku?: number; /** Public URL to the design (needed for Printful API) */ designImageUrl?: string; /** Skip cache */ fresh?: boolean; }, ): Promise { const type = mockupType === "poster" ? "poster" : mockupType; const cacheKey = `${type}-${designBuffer.length}`; if (!options?.fresh) { const cached = mockupCache.get(cacheKey); if (cached) return cached; } // Try Printful API if we have a SKU and public URL if (options?.printfulSku && options?.designImageUrl) { const pfResult = await printfulMockup( options.designImageUrl, options.printfulSku, ); if (pfResult) { mockupCache.set(cacheKey, pfResult); evictCache(); return pfResult; } } // Local fallback const result = await localComposite(designBuffer, type); mockupCache.set(cacheKey, result); evictCache(); return result; } /** List available mockup types. */ export const MOCKUP_TYPES = Object.keys(TEMPLATES);