@prodbirdy/mockup-generator
Version:
Serverless-optimized TypeScript SDK for generating high-quality product mockups from PSD templates
253 lines (228 loc) • 7.68 kB
text/typescript
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 };