240 lines
6.9 KiB
TypeScript
240 lines
6.9 KiB
TypeScript
/**
|
|
* 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<string, MockupTemplate> = {
|
|
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<string, Buffer>();
|
|
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<Buffer> {
|
|
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 `<svg width="${w}" height="${h}" xmlns="http://www.w3.org/2000/svg">
|
|
<rect width="${w}" height="${h}" fill="#0f172a"/>
|
|
<path d="M${w * 0.25},${h * 0.08} L${w * 0.12},${h * 0.12} L${w * 0.04},${h * 0.3} L${w * 0.16},${h * 0.34} L${w * 0.2},${h * 0.22} L${w * 0.2},${h * 0.88} L${w * 0.8},${h * 0.88} L${w * 0.8},${h * 0.22} L${w * 0.84},${h * 0.34} L${w * 0.96},${h * 0.3} L${w * 0.88},${h * 0.12} L${w * 0.75},${h * 0.08} Q${w * 0.65},${h * 0.15} ${w * 0.5},${h * 0.15} Q${w * 0.35},${h * 0.15} ${w * 0.25},${h * 0.08} Z" fill="#1a1a1a" stroke="#334155" stroke-width="2"/>
|
|
<rect x="${w * 0.256}" y="${h * 0.225}" width="${w * 0.488}" height="${h * 0.44}" rx="8" fill="none" stroke="#4f46e5" stroke-width="1" stroke-dasharray="6,4" opacity="0.4"/>
|
|
</svg>`;
|
|
}
|
|
|
|
function generateStickerSvg(w: number, h: number): string {
|
|
return `<svg width="${w}" height="${h}" xmlns="http://www.w3.org/2000/svg">
|
|
<rect width="${w}" height="${h}" fill="#0f172a"/>
|
|
<rect x="${w * 0.08}" y="${h * 0.08}" width="${w * 0.84}" height="${h * 0.84}" rx="32" fill="#1e293b" stroke="#334155" stroke-width="2"/>
|
|
<rect x="${w * 0.12}" y="${h * 0.12}" width="${w * 0.76}" height="${h * 0.76}" rx="24" fill="none" stroke="#4f46e5" stroke-width="1" stroke-dasharray="6,4" opacity="0.4"/>
|
|
</svg>`;
|
|
}
|
|
|
|
// ── Printful API mockup ──
|
|
|
|
async function printfulMockup(
|
|
designImageUrl: string,
|
|
printfulSku: number,
|
|
variantIds?: number[],
|
|
): Promise<Buffer | null> {
|
|
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<number | null> {
|
|
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<Buffer> {
|
|
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);
|