@prodbirdy/mockup-generator
Version:
Serverless-optimized TypeScript SDK for generating high-quality product mockups from PSD templates
628 lines (541 loc) • 16.6 kB
text/typescript
/**
* MockupSDK - Main SDK class for mockup generation
* Provides a clean, simple API for serverless environments
*/
import { EventEmitter } from "events";
import { initializeCanvas } from "ag-psd";
import { createCanvas } from "canvas";
import type {
MockupSDKConfig,
MockupSDKMode,
MockupGenerationConfig,
BatchMockupConfig,
MockupResult,
BatchMockupResult,
UploadOptions,
UploadResult,
StorageConfig,
ExportResult,
} from "./types";
import {
MockupSDKError,
ValidationError,
StorageError,
ExportError,
} from "./types";
import { MockupEngine } from "./MockupEngine";
import { HeadlessPhotopeaEngine } from "./HeadlessPhotopeaEngine";
import { StorageManager } from "./StorageManager";
import {
validateGenerationConfig,
validateBatchConfig,
validateUploadOptions,
} from "./validation";
/**
* Mode presets for common configurations
*/
const MODE_PRESETS: Record<MockupSDKMode, Partial<MockupSDKConfig>> = {
serverless: {
headless: true,
timeout: 45000,
verbose: false,
},
development: {
headless: false,
timeout: 120000,
verbose: true,
},
production: {
headless: true,
timeout: 60000,
verbose: false,
},
auto: {
// Will be resolved based on environment detection
headless: true,
timeout: 60000,
verbose: false,
},
};
export class MockupSDK extends EventEmitter {
private mockupEngine: MockupEngine;
private exportEngine: HeadlessPhotopeaEngine | null = null;
private storageManager: StorageManager | null;
private config: MockupSDKConfig;
private constructor(config: MockupSDKConfig = {}) {
super();
// Initialize canvas for ag-psd before any PSD operations
this.initializeCanvasSupport();
// Resolve configuration with mode presets
this.config = this.resolveConfig(config);
// Initialize components - storage is now optional
this.storageManager = this.config.storage
? new StorageManager(this.config.storage)
: null;
this.mockupEngine = new MockupEngine(this.config);
}
/**
* Factory method to create and initialize MockupSDK
*/
static async create(config: MockupSDKConfig = {}): Promise<MockupSDK> {
const instance = new MockupSDK(config);
await instance.initialize();
return instance;
}
/**
* Initialize async components
*/
private async initialize(): Promise<void> {
// Initialize HeadlessPhotopeaEngine
this.exportEngine = await HeadlessPhotopeaEngine.create(this.config);
// Forward events
this.setupEventForwarding();
}
/**
* Generate a single mockup from configuration
*
* @param config - Mockup generation configuration
* @returns Promise<MockupResult>
*/
async generate(config: MockupGenerationConfig): Promise<MockupResult> {
if (!this.exportEngine) {
throw new MockupSDKError(
"MockupSDK not initialized. Use MockupSDK.create() instead of constructor.",
"NOT_INITIALIZED"
);
}
try {
// Validate configuration
validateGenerationConfig(config);
this.emit("progress", "validation", 10);
const startTime = Date.now();
// Step 1: Generate PSD with replacements
this.emit("progress", "generating-psd", 20);
const psdBuffer = await this.mockupEngine.generatePSD(
config.psd,
config.replacements
);
this.emit("psd-generated", psdBuffer);
// Step 2: Export to requested formats directly from buffer (optimal for generated PSDs)
let exportResults: ExportResult[] = [];
if (config.exports && config.exports.length > 0) {
this.emit("progress", "exporting", 60);
if (config.exports.length === 1) {
// Single export - use individual method
const exportOption = config.exports[0];
this.emit("export-started", exportOption?.format);
const result = await this.exportEngine.exportFormat(
psdBuffer,
exportOption!
);
exportResults.push(result);
this.emit("export-completed", result);
} else {
// Multiple exports - use optimized batch method (single browser session)
for (const exportOption of config.exports) {
this.emit("export-started", exportOption.format);
}
exportResults = await this.exportEngine.exportMultipleFormats(
psdBuffer,
config.exports
);
for (const result of exportResults) {
this.emit("export-completed", result);
}
}
}
this.emit("progress", "completed", 100);
const processingTime = Date.now() - startTime;
return {
psd: psdBuffer,
exports: exportResults,
metadata: {
processingTime,
sourceDimensions: await this.getPSDDimensions(config.psd),
replacementsCount: config.replacements.length,
},
};
} catch (error) {
const sdkError = this.wrapError(error);
this.emit("error", sdkError);
throw sdkError;
}
}
/**
* Generate multiple mockup variations from a batch configuration
*
* @param config - Batch mockup configuration
* @returns Promise<BatchMockupResult>
*/
async generateBatch(config: BatchMockupConfig): Promise<BatchMockupResult> {
try {
// Validate configuration
validateBatchConfig(config);
this.emit("progress", "batch-validation", 5);
const batchStartTime = Date.now();
const results = [];
const errors = [];
// Process each variation
for (const [index, variation] of config.variations.entries()) {
try {
this.emit(
"progress",
`batch-item-${variation.id}`,
(index / config.variations.length) * 90 + 10
);
const mockupConfig: MockupGenerationConfig = {
psd: config.template,
replacements: variation.replacements,
exports: variation.exports,
};
const result = await this.generate(mockupConfig);
results.push({
id: variation.id,
psd: result.psd,
exports: result.exports,
generationMetadata: result.metadata,
variationMetadata: variation.metadata,
});
} catch (error) {
errors.push({
id: variation.id,
error: error instanceof Error ? error.message : String(error),
});
if (this.config.verbose) {
console.error(
`Failed to generate variation ${variation.id}:`,
error
);
}
}
}
this.emit("progress", "batch-completed", 100);
const totalProcessingTime = Date.now() - batchStartTime;
return {
results,
batchMetadata: {
totalProcessingTime,
successCount: results.length,
failureCount: errors.length,
errors,
},
};
} catch (error) {
const sdkError = this.wrapError(error);
this.emit("error", sdkError);
throw sdkError;
}
}
/**
* Upload mockup results to storage
*
* @param result - Mockup result to upload
* @param options - Upload options
* @returns Promise<UploadResult>
*/
async upload(
result: MockupResult,
options: UploadOptions = {}
): Promise<UploadResult> {
try {
validateUploadOptions(options);
const startTime = Date.now();
// Get storage manager (from options or SDK config)
const storage = this.getStorageManager(options.storage);
const uploadPSD = options.uploadPSD ?? true;
const uploadExports = options.uploadExports ?? true;
const prefix = options.prefix || "mockups";
let psdUrl: string | undefined;
const exportUrls: Record<string, string> = {};
const sizes: Record<string, number> = {};
// Upload PSD if requested
if (uploadPSD) {
this.emit("progress", "uploading-psd", 20);
psdUrl = await storage.uploadPSD(result.psd, prefix);
sizes.psd = result.psd.length;
}
// Upload exports if requested
if (uploadExports) {
for (const [index, exportResult] of result.exports.entries()) {
this.emit(
"progress",
`uploading-${exportResult.format}`,
20 + (index / result.exports.length) * 60
);
const uploadResult = await storage.uploadExport(
exportResult.buffer,
exportResult.format,
`${prefix}/exports`
);
exportUrls[exportResult.format] = uploadResult.url;
sizes[exportResult.format] = exportResult.size;
}
}
const uploadTime = Date.now() - startTime;
return {
psdUrl,
exportUrls,
metadata: {
uploadTime,
sizes,
},
};
} catch (error) {
const sdkError = this.wrapError(error);
this.emit("error", sdkError);
throw sdkError;
}
}
/**
* Upload batch mockup results to storage
*
* @param batchResult - Batch result to upload
* @param options - Upload options
* @returns Promise<UploadResult[]>
*/
async uploadBatch(
batchResult: BatchMockupResult,
options: UploadOptions = {}
): Promise<UploadResult[]> {
const results: UploadResult[] = [];
for (const [index, result] of batchResult.results.entries()) {
try {
this.emit(
"progress",
`batch-upload-${result.id}`,
(index / batchResult.results.length) * 100
);
const uploadOptions = {
...options,
prefix: options.prefix ? `${options.prefix}/${result.id}` : result.id,
};
const uploadResult = await this.upload(result, uploadOptions);
results.push(uploadResult);
} catch (error) {
if (this.config.verbose) {
console.error(`Failed to upload result ${result.id}:`, error);
}
// Continue with other uploads
}
}
return results;
}
/**
* Utility method to inspect a PSD structure
*/
async analyze(psd: string | Buffer): Promise<{
dimensions: { width: number; height: number };
smartObjects: Array<{
name: string;
dimensions: { width: number; height: number };
path: string[];
}>;
layers: number;
}> {
return this.mockupEngine.analyzePSD(psd);
}
/**
* Get storage info (buckets, usage, etc.)
*/
async getStorageInfo(): Promise<{
bucket: string;
region?: string;
connected: boolean;
}> {
if (!this.storageManager) {
return { bucket: "", connected: false };
}
return this.storageManager.getInfo();
}
/**
* Cleanup resources (important for serverless environments)
*/
async cleanup(): Promise<void> {
await this.exportEngine?.cleanup();
await this.storageManager?.cleanup?.();
}
// Private methods
/**
* Initialize canvas support for ag-psd
* This must be called before any PSD operations
*/
private initializeCanvasSupport(): void {
try {
initializeCanvas((width: number, height: number) => {
const canvas = createCanvas(width, height);
canvas.width = width;
canvas.height = height;
return canvas as any;
});
} catch (error) {
throw new MockupSDKError(
"Failed to initialize canvas. Make sure 'canvas' package is installed: npm install canvas",
"CANVAS_INIT_ERROR",
error
);
}
}
private resolveConfig(config: MockupSDKConfig): MockupSDKConfig {
const mode = config.mode || "production";
// Get base preset
let baseConfig = { ...MODE_PRESETS[mode] };
// Handle auto mode - detect environment
if (mode === "auto") {
baseConfig = this.detectEnvironmentConfig();
// Try to load storage from environment
if (!config.storage) {
const envStorage = this.loadStorageFromEnvironment();
if (envStorage) {
baseConfig.storage = envStorage;
}
}
}
// Override with user config (user config takes precedence)
const resolvedConfig = {
...baseConfig,
...config,
mode,
};
return resolvedConfig;
}
private detectEnvironmentConfig(): Partial<MockupSDKConfig> {
// Check for serverless environment indicators
if (
process.env.AWS_LAMBDA_FUNCTION_NAME ||
process.env.VERCEL ||
process.env.CF_PAGES
) {
return MODE_PRESETS.serverless;
}
// Check NODE_ENV
if (process.env.NODE_ENV === "development") {
return MODE_PRESETS.development;
}
if (process.env.NODE_ENV === "production") {
return MODE_PRESETS.production;
}
// Default to production
return MODE_PRESETS.production;
}
private loadStorageFromEnvironment(): StorageConfig | null {
const endpoint = process.env.R2_ENDPOINT || process.env.S3_ENDPOINT;
const accessKeyId =
process.env.R2_ACCESS_KEY_ID || process.env.AWS_ACCESS_KEY_ID;
const secretAccessKey =
process.env.R2_SECRET_ACCESS_KEY || process.env.AWS_SECRET_ACCESS_KEY;
const bucket = process.env.R2_BUCKET || process.env.S3_BUCKET;
const region = process.env.R2_REGION || process.env.AWS_REGION;
if (endpoint && accessKeyId && secretAccessKey && bucket) {
return {
endpoint,
accessKeyId,
secretAccessKey,
bucket,
region,
};
}
return null;
}
private getStorageManager(storageConfig?: StorageConfig): StorageManager {
if (storageConfig) {
return new StorageManager(storageConfig);
}
if (this.storageManager) {
return this.storageManager;
}
throw new StorageError(
"No storage configuration available. Provide storage config in SDK constructor or upload options."
);
}
private async getPSDDimensions(
psd: string | Buffer
): Promise<{ width: number; height: number }> {
try {
const analysis = await this.mockupEngine.analyzePSD(psd);
return analysis.dimensions;
} catch {
return { width: 0, height: 0 }; // Fallback
}
}
/**
* Convenience factory methods (thin wrappers around mode-based config)
*/
/**
* Create SDK with serverless mode preset
*/
static async forServerless(config: Omit<MockupSDKConfig, "mode"> = {}): Promise<MockupSDK> {
return MockupSDK.create({ mode: "serverless", ...config });
}
/**
* Create SDK with development mode preset
*/
static async forDevelopment(config: Omit<MockupSDKConfig, "mode"> = {}): Promise<MockupSDK> {
return MockupSDK.create({ mode: "development", ...config });
}
/**
* Create SDK with production mode preset
*/
static async forProduction(config: Omit<MockupSDKConfig, "mode"> = {}): Promise<MockupSDK> {
return MockupSDK.create({ mode: "production", ...config });
}
/**
* Create SDK with auto mode (detects environment automatically)
*/
static async auto(config: Omit<MockupSDKConfig, "mode"> = {}): Promise<MockupSDK> {
return MockupSDK.create({ mode: "auto", ...config });
}
// Private methods
private setupEventForwarding(): void {
// Forward events from internal components
this.mockupEngine.on("progress", (stage, progress) =>
this.emit("progress", stage, progress)
);
this.exportEngine?.on("progress", (stage, progress) =>
this.emit("progress", stage, progress)
);
this.storageManager?.on("progress", (stage, progress) =>
this.emit("progress", stage, progress)
);
}
private wrapError(error: any): MockupSDKError {
if (error instanceof MockupSDKError) {
return error;
}
if (
error.message?.includes("storage") ||
error.message?.includes("upload")
) {
return new StorageError(error.message, error);
}
if (
error.message?.includes("export") ||
error.message?.includes("photopea")
) {
return new ExportError(error.message, error);
}
if (
error.message?.includes("validation") ||
error.message?.includes("invalid")
) {
return new ValidationError(error.message, error);
}
return new MockupSDKError(
error.message || "Unknown error",
"UNKNOWN_ERROR",
error
);
}
}
// Export types for consumers
export type {
MockupSDKConfig,
SmartObjectReplacement,
ExportOptions,
MockupResult,
ExportResult,
MockupSDKEvents,
MockupSDKError,
ValidationError,
StorageError,
ExportError,
} from "./types";