rspace-online/modules/rswag/mockup.ts

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