UNPKG

@prodbirdy/mockup-generator

Version:

Serverless-optimized TypeScript SDK for generating high-quality product mockups from PSD templates

253 lines (228 loc) 7.68 kB
import { tmpdir } from "os"; import { join } from "path"; import puppeteer, { Browser, Page } from "puppeteer"; import { randomBytes } from "crypto"; import { template } from "./template"; import fs from "node:fs/promises"; interface HeadlessPhotopeaOptions { showBrowser?: boolean; logFunction?: (msg: string) => void; maxWaitTime?: number; } class HeadlessPhotopea { browser?: Browser; page?: Page; initialized: boolean = false; options: Required<HeadlessPhotopeaOptions> = { showBrowser: true, logFunction: console.log, maxWaitTime: 60000, }; /** * Private constructor to prevent direct instantiation. * @param options Settings to override the defaults. */ constructor(options: HeadlessPhotopeaOptions = {}) { for (let key in options) { (this.options as any)[key] = options[key as keyof HeadlessPhotopeaOptions]; } } /** * Factory method to create and initialize HeadlessPhotopea * @param options Settings to override the defaults. * @returns Fully initialized HeadlessPhotopea instance */ static async create(options: HeadlessPhotopeaOptions = {}): Promise<HeadlessPhotopea> { const instance = new HeadlessPhotopea(options); await instance.initialize(); return instance; } /** * Initialize the HeadlessPhotopea instance */ async initialize(): Promise<void> { try { this.browser = await puppeteer.launch({ headless: this.options.showBrowser ? false : true, args: [ "--no-sandbox", "--disable-web-security", "--disable-features=IsolateOrigins,site-per-process", ], defaultViewport: null, }); this.page = await this.browser.newPage(); this.page.setDefaultTimeout(this.options.maxWaitTime); const tempFile = join( tmpdir(), `mockup-${randomBytes(16).toString("hex")}.html` ); await fs.writeFile(tempFile, template); await this.page.goto(`file://${tempFile}`, { waitUntil: "networkidle0" }); await this.page.waitForSelector("#readyTag"); this.initialized = true; this.logMessage("photopea initialized!"); } catch (error) { // Clean up on initialization failure if (this.browser) { try { await this.browser.close(); } catch { // Ignore cleanup errors } this.browser = undefined; } this.initialized = false; throw new Error( `Failed to initialize HeadlessPhotopea: ${error instanceof Error ? error.message : String(error) }` ); } } /** * Wait for window to be initialized * @returns true when Photopea is ready */ async isInitialized(): Promise<boolean> { return new Promise((resolve) => { const check = () => { if (this.initialized) resolve(true); else setTimeout(check, 100); }; check(); }); } /** * Run a script in Photopea. * @param script script to run. * @returns output from Photopea, ending with "done". All ArrayBuffers will be converted to base 64 strings. */ async runScript(script: string): Promise<any[]> { await this.isInitialized(); if (!this.page) throw new Error("Page not initialized"); let res = await this.page.evaluate(` new Promise(function(resolve, reject) { pea.runScript(${JSON.stringify(script)}).then(function(out) { for (let i = 0; i < out.length; i++) { if (out[i] instanceof ArrayBuffer) { out[i] = base64ArrayBuffer(out[i]); } } resolve(out); }); }) `) as any[]; return res; } /** * Same as loadAsset; this was kept for backwards compatibility * @param buff file to load into Photopea. * @returns true, once the file is loaded. */ async addBinaryAsset(buff: Buffer): Promise<boolean> { await this.isInitialized(); if (!this.page) throw new Error("Page not initialized"); let b64 = buff.toString("base64"); let res = await this.page.evaluate(` new Promise(async function(resolve, reject) { let b64 = ${JSON.stringify(b64)}; let binStr = atob(b64); let bytes = new Uint8Array(binStr.length); for (let i = 0; i < binStr.length; i++) bytes[i] = binStr.charCodeAt(i); resolve(await pea.loadAsset(bytes.buffer)); }) `) as boolean; return res; } /** * Open a file in Photopea. * @param asset file to load into Photopea. * @returns true, once the file is loaded. */ async loadAsset(asset: Buffer): Promise<boolean> { return await this.addBinaryAsset(asset); } /** * Open a file in Photopea from a URL. * @param url url of asset. make sure it can be accessed cross-origin * @param asSmart open as smart object? * @returns true, once the file is opened. */ async openFromURL(url: string, asSmart: boolean = true): Promise<boolean> { await this.isInitialized(); if (!this.page) throw new Error("Page not initialized"); let res = await this.page.evaluate(` new Promise(function(resolve, reject) { pea.openFromURL(${JSON.stringify(url)}, ${asSmart ? "true" : "false" }).then(function(out) { resolve(out); }); }) `); return true; } /** * Return the document image as a Buffer. * @param type type of image to export. */ async exportImage(type: "png" | "jpg" | "webp"): Promise<Buffer> { await this.isInitialized(); if (!this.page) throw new Error("Page not initialized"); let res = await this.page.evaluate(` new Promise(async function(resolve, reject) { await pea._pause(); let buffer = "done"; while (buffer == "done") { let data = await pea.runScript("app.activeDocument.saveToOE('${type}');"); buffer = data[0]; } let b64 = base64ArrayBuffer(buffer); resolve(b64); }) `); // @ts-ignore - page.evaluate returns unknown, but we know it's a base64 string return Buffer.from(res, "base64"); } /** * Save a debugging screenshot to file * @param fName Absolute path name of the file */ async screenshot(fName: string): Promise<void> { if (!this.page) throw new Error("Page not initialized"); // @ts-ignore - puppeteer screenshot path type is too restrictive await this.page.screenshot({ // @ts-ignore path: fName, }); } /** * Kill this window. */ async destroy(): Promise<void> { if (this.browser) { await this.browser.close(); } } /** * Log a message. * @param msg Line to save to log. */ logMessage(msg: string): void { this.options.logFunction(msg); } /** * Restart the browser in an emergency situation. */ async emergencyRestart(): Promise<void> { this.logMessage("ERROR CAUGHT: EMERGENCY RESTART SEQUENCE INITIATED"); this.initialized = false; if (!this.page) throw new Error("Page not initialized"); await this.page.evaluate(`document.querySelector("iframe").remove();`); await this.page.setContent(template, { waitUntil: "networkidle0" }); await this.page.waitForSelector("#readyTag"); this.initialized = true; this.logMessage("restart sequence completed!"); } } export default HeadlessPhotopea; export { HeadlessPhotopea, type HeadlessPhotopeaOptions };