380 lines
14 KiB
TypeScript
380 lines
14 KiB
TypeScript
import { chromium } from "playwright-core";
|
|
import { Writable } from "stream";
|
|
import archiver from "archiver";
|
|
import sharp from "sharp";
|
|
import { puterChatComplete } from "../puter-inference.js";
|
|
import { resolveBrowserPath } from "./diagram-renderer.js";
|
|
import { validateAndCleanSvg } from "./icon-renderer.js";
|
|
import type { RenderResult, BrandKitBundle } from "./types.js";
|
|
|
|
// ─── Brand specification ────────────────────────────────────────────────────────
|
|
|
|
interface BrandSpec {
|
|
name: string;
|
|
tagline: string;
|
|
primaryColor: string;
|
|
secondaryColor: string;
|
|
fontStyle: string;
|
|
industry: string;
|
|
logoDescription: string;
|
|
}
|
|
|
|
// ─── Social platform dimensions ─────────────────────────────────────────────────
|
|
|
|
const SOCIAL_DIMENSIONS: Record<string, { width: number; height: number }> = {
|
|
"twitter-profile": { width: 400, height: 400 },
|
|
"twitter-banner": { width: 1500, height: 500 },
|
|
"linkedin-profile": { width: 400, height: 400 },
|
|
"linkedin-banner": { width: 1584, height: 396 },
|
|
"instagram-profile": { width: 1080, height: 1080 },
|
|
};
|
|
|
|
// ─── Strip markdown fences ──────────────────────────────────────────────────────
|
|
|
|
function stripMarkdownFences(raw: string): string {
|
|
return raw
|
|
.trim()
|
|
.replace(/^```(?:json|svg|xml|html)?\s*/i, "")
|
|
.replace(/\s*```$/, "")
|
|
.trim();
|
|
}
|
|
|
|
// ─── Step 1: Extract brand spec from prompt ─────────────────────────────────────
|
|
|
|
async function extractBrandSpec(prompt: string): Promise<BrandSpec> {
|
|
const systemPrompt = [
|
|
"You are a brand identity strategist.",
|
|
"Extract or invent a brand specification from the user's prompt.",
|
|
"Respond with ONLY a JSON object — no markdown fences, no explanation.",
|
|
"Required fields:",
|
|
' "name": brand name (string)',
|
|
' "tagline": short catchy tagline (string)',
|
|
' "primaryColor": primary brand color as hex (e.g. "#FF5733")',
|
|
' "secondaryColor": secondary brand color as hex (e.g. "#33C1FF")',
|
|
' "fontStyle": one of "sans", "serif", or "mono"',
|
|
' "industry": industry sector (string)',
|
|
' "logoDescription": brief description of a simple geometric logo mark (string)',
|
|
].join("\n");
|
|
|
|
const raw = await puterChatComplete([
|
|
{ role: "system", content: systemPrompt },
|
|
{ role: "user", content: prompt },
|
|
]);
|
|
|
|
try {
|
|
const cleaned = stripMarkdownFences(raw);
|
|
const parsed = JSON.parse(cleaned) as Partial<BrandSpec>;
|
|
return {
|
|
name: parsed.name ?? "Brand",
|
|
tagline: parsed.tagline ?? "Building the future",
|
|
primaryColor: parsed.primaryColor ?? "#3B82F6",
|
|
secondaryColor: parsed.secondaryColor ?? "#10B981",
|
|
fontStyle: parsed.fontStyle ?? "sans",
|
|
industry: parsed.industry ?? "technology",
|
|
logoDescription: parsed.logoDescription ?? "A simple geometric mark",
|
|
};
|
|
} catch {
|
|
// Fallback defaults if parsing fails
|
|
return {
|
|
name: "Brand",
|
|
tagline: "Building the future",
|
|
primaryColor: "#3B82F6",
|
|
secondaryColor: "#10B981",
|
|
fontStyle: "sans",
|
|
industry: "technology",
|
|
logoDescription: "A simple geometric mark",
|
|
};
|
|
}
|
|
}
|
|
|
|
// ─── Step 2: Generate logo SVG via LLM ─────────────────────────────────────────
|
|
|
|
async function generateLogoSvg(spec: BrandSpec): Promise<string> {
|
|
const systemPrompt = [
|
|
"You are an SVG logo designer.",
|
|
"Output ONLY valid SVG. No text explanations. No markdown fences.",
|
|
"Simple geometric shapes only. viewBox 0 0 512 512.",
|
|
`Use only these colors: primary=${spec.primaryColor}, secondary=${spec.secondaryColor}`,
|
|
"The SVG must start with <svg and end with </svg>.",
|
|
"No text elements, no external resources, no scripts.",
|
|
].join("\n");
|
|
|
|
const raw = await puterChatComplete([
|
|
{ role: "system", content: systemPrompt },
|
|
{ role: "user", content: `Create a logo mark for: ${spec.logoDescription}` },
|
|
]);
|
|
|
|
const cleaned = stripMarkdownFences(raw);
|
|
const { svg } = validateAndCleanSvg(cleaned);
|
|
return svg;
|
|
}
|
|
|
|
// ─── Step 3: Rasterize logo to avatar sizes ─────────────────────────────────────
|
|
|
|
async function rasterizeAvatars(logoSvg: string): Promise<Record<string, string>> {
|
|
const sizes = [512, 256, 128, 64, 32];
|
|
const avatarPngs: Record<string, string> = {};
|
|
|
|
for (const size of sizes) {
|
|
const pngBuffer = await sharp(Buffer.from(logoSvg))
|
|
.resize(size, size)
|
|
.png()
|
|
.toBuffer();
|
|
avatarPngs[String(size)] = pngBuffer.toString("base64");
|
|
}
|
|
|
|
return avatarPngs;
|
|
}
|
|
|
|
// ─── Step 4: Generate social platform images ────────────────────────────────────
|
|
|
|
async function generateSocialImages(
|
|
spec: BrandSpec,
|
|
logoSvg: string,
|
|
): Promise<Record<string, string>> {
|
|
const socialImages: Record<string, string> = {};
|
|
|
|
for (const [platform, dims] of Object.entries(SOCIAL_DIMENSIONS)) {
|
|
const { width, height } = dims;
|
|
|
|
// Build SVG template: colored background + centered logo
|
|
const logoScale = Math.min(width, height) * 0.5;
|
|
const logoX = (width - logoScale) / 2;
|
|
const logoY = (height - logoScale) / 2;
|
|
|
|
const socialSvg = [
|
|
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}">`,
|
|
` <rect width="${width}" height="${height}" fill="${spec.primaryColor}"/>`,
|
|
` <image x="${logoX}" y="${logoY}" width="${logoScale}" height="${logoScale}" href="data:image/svg+xml;base64,${Buffer.from(logoSvg).toString("base64")}"/>`,
|
|
`</svg>`,
|
|
].join("\n");
|
|
|
|
const pngBuffer = await sharp(Buffer.from(socialSvg), { density: 72 })
|
|
.resize(width, height, { fit: "fill" })
|
|
.png({ compressionLevel: 9 })
|
|
.toBuffer();
|
|
|
|
socialImages[platform] = pngBuffer.toString("base64");
|
|
}
|
|
|
|
return socialImages;
|
|
}
|
|
|
|
// ─── Step 5: Generate email signature and letterhead HTML ───────────────────────
|
|
|
|
async function generateTemplates(
|
|
spec: BrandSpec,
|
|
): Promise<{ signature: string; letterhead: string }> {
|
|
const signatureSystemPrompt = [
|
|
"You are a professional HTML email signature designer.",
|
|
"Generate a professional HTML email signature with name, tagline, colors.",
|
|
"Inline CSS only. No external resources. Keep under 200 lines.",
|
|
"Output ONLY the HTML — no markdown fences, no explanation.",
|
|
`Brand: name="${spec.name}", tagline="${spec.tagline}", primary=${spec.primaryColor}, secondary=${spec.secondaryColor}`,
|
|
].join("\n");
|
|
|
|
const letterheadSystemPrompt = [
|
|
"You are a professional HTML document designer.",
|
|
"Generate a professional HTML letterhead template with header, footer, placeholder body.",
|
|
"Inline CSS only. No external resources.",
|
|
"Output ONLY the HTML — no markdown fences, no explanation.",
|
|
`Brand: name="${spec.name}", tagline="${spec.tagline}", primary=${spec.primaryColor}, secondary=${spec.secondaryColor}`,
|
|
].join("\n");
|
|
|
|
const [rawSignature, rawLetterhead] = await Promise.all([
|
|
puterChatComplete([
|
|
{ role: "system", content: signatureSystemPrompt },
|
|
{ role: "user", content: `Create an email signature for ${spec.name}` },
|
|
]),
|
|
puterChatComplete([
|
|
{ role: "system", content: letterheadSystemPrompt },
|
|
{ role: "user", content: `Create a letterhead template for ${spec.name}` },
|
|
]),
|
|
]);
|
|
|
|
return {
|
|
signature: stripMarkdownFences(rawSignature),
|
|
letterhead: stripMarkdownFences(rawLetterhead),
|
|
};
|
|
}
|
|
|
|
// ─── Step 6: Generate brand guidelines PDF via Playwright ───────────────────────
|
|
|
|
async function generateGuidelinesPdf(
|
|
spec: BrandSpec,
|
|
browser: Awaited<ReturnType<typeof chromium.launch>>,
|
|
): Promise<Buffer> {
|
|
const guidelinesSystemPrompt = [
|
|
"You are a professional brand designer.",
|
|
"Generate a brand guidelines document as HTML.",
|
|
"Include: brand name, tagline, color palette with hex codes, typography guidance, logo usage rules, spacing guidelines.",
|
|
"Inline CSS only. No external resources. Use web-safe fonts.",
|
|
"Output ONLY a complete HTML document starting with <!DOCTYPE html> — no markdown fences, no explanation.",
|
|
`Brand: name="${spec.name}", tagline="${spec.tagline}", primary=${spec.primaryColor}, secondary=${spec.secondaryColor}, fontStyle=${spec.fontStyle}, industry=${spec.industry}`,
|
|
].join("\n");
|
|
|
|
const rawHtml = await puterChatComplete([
|
|
{ role: "system", content: guidelinesSystemPrompt },
|
|
{ role: "user", content: `Create brand guidelines for ${spec.name}` },
|
|
]);
|
|
|
|
const cleanHtml = stripMarkdownFences(rawHtml);
|
|
|
|
const page = await browser.newPage();
|
|
const pdfUint8 = await (async () => {
|
|
await page.setContent(cleanHtml, { waitUntil: "domcontentloaded" });
|
|
return page.pdf({
|
|
format: "A4",
|
|
printBackground: true,
|
|
margin: { top: "20mm", bottom: "20mm", left: "20mm", right: "20mm" },
|
|
});
|
|
})();
|
|
|
|
return Buffer.from(pdfUint8);
|
|
}
|
|
|
|
// ─── Step 7: Package all assets into a ZIP buffer ──────────────────────────────
|
|
|
|
interface ZipAssets {
|
|
logoSvg: string;
|
|
avatarPngs: Record<string, string>;
|
|
socialImages: Record<string, string>;
|
|
signature: string;
|
|
letterhead: string;
|
|
guidelinesPdf: Buffer;
|
|
}
|
|
|
|
async function buildZip(assets: ZipAssets): Promise<Buffer> {
|
|
return new Promise((resolve, reject) => {
|
|
const chunks: Buffer[] = [];
|
|
const sink = new Writable({
|
|
write(chunk: Buffer, _enc, cb) {
|
|
chunks.push(chunk);
|
|
cb();
|
|
},
|
|
});
|
|
|
|
const archive = archiver("zip", { zlib: { level: 6 } });
|
|
archive.on("error", reject);
|
|
sink.on("finish", () => resolve(Buffer.concat(chunks)));
|
|
archive.pipe(sink);
|
|
|
|
// Logo SVG
|
|
archive.append(Buffer.from(assets.logoSvg), { name: "brand-kit/logo/logo.svg" });
|
|
|
|
// Avatar PNGs
|
|
for (const size of ["512", "256", "128", "64", "32"]) {
|
|
const data = assets.avatarPngs[size];
|
|
if (data) {
|
|
archive.append(Buffer.from(data, "base64"), {
|
|
name: `brand-kit/logo/logo-${size}.png`,
|
|
});
|
|
}
|
|
}
|
|
|
|
// Social images
|
|
for (const [platform, data] of Object.entries(assets.socialImages)) {
|
|
if (data) {
|
|
archive.append(Buffer.from(data, "base64"), {
|
|
name: `brand-kit/social/${platform}.png`,
|
|
});
|
|
}
|
|
}
|
|
|
|
// Templates
|
|
archive.append(Buffer.from(assets.signature), {
|
|
name: "brand-kit/templates/email-signature.html",
|
|
});
|
|
archive.append(Buffer.from(assets.letterhead), {
|
|
name: "brand-kit/templates/letterhead.html",
|
|
});
|
|
|
|
// Guidelines PDF
|
|
archive.append(assets.guidelinesPdf, { name: "brand-kit/guidelines.pdf" });
|
|
|
|
void archive.finalize();
|
|
});
|
|
}
|
|
|
|
// ─── Main exported renderer ─────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Render a full brand identity kit from a single conversation prompt.
|
|
*
|
|
* Orchestrates: spec extraction → logo SVG → avatar rasterization →
|
|
* social images → email signature + letterhead → guidelines PDF → ZIP package.
|
|
*
|
|
* Returns a RenderResult containing a BrandKitBundle JSON bundle.
|
|
*/
|
|
export async function renderBrandKit(
|
|
input: Record<string, unknown>,
|
|
): Promise<RenderResult> {
|
|
const prompt =
|
|
typeof input.prompt === "string" ? input.prompt : "Create a brand identity";
|
|
|
|
// Step 1: Extract brand spec from prompt
|
|
const spec = await extractBrandSpec(prompt);
|
|
|
|
// Step 2: Generate logo SVG via LLM
|
|
const logoSvg = await generateLogoSvg(spec);
|
|
|
|
// Step 3: Rasterize logo to 5 avatar sizes
|
|
const avatarPngs = await rasterizeAvatars(logoSvg);
|
|
|
|
// Step 4: Generate social platform images (colored background + centered logo)
|
|
const socialImages = await generateSocialImages(spec, logoSvg);
|
|
|
|
// Step 5: Generate email signature and letterhead HTML
|
|
const { signature, letterhead } = await generateTemplates(spec);
|
|
|
|
// Step 6: Open Playwright browser ONCE for the entire brand kit job
|
|
const executablePath = resolveBrowserPath();
|
|
const browser = await chromium.launch({
|
|
executablePath,
|
|
headless: true,
|
|
args: ["--no-sandbox", "--disable-setuid-sandbox"],
|
|
});
|
|
|
|
let guidelinesPdf: Buffer;
|
|
try {
|
|
guidelinesPdf = await generateGuidelinesPdf(spec, browser);
|
|
} finally {
|
|
await browser.close();
|
|
}
|
|
|
|
// Step 7: Package everything into a ZIP
|
|
const zipBuffer = await buildZip({
|
|
logoSvg,
|
|
avatarPngs,
|
|
socialImages,
|
|
signature,
|
|
letterhead,
|
|
guidelinesPdf,
|
|
});
|
|
|
|
// Assemble the BrandKitBundle
|
|
const bundle: BrandKitBundle = {
|
|
type: "brand-kit-bundle",
|
|
spec: {
|
|
name: spec.name,
|
|
tagline: spec.tagline,
|
|
primaryColor: spec.primaryColor,
|
|
secondaryColor: spec.secondaryColor,
|
|
fontStyle: spec.fontStyle,
|
|
industry: spec.industry,
|
|
},
|
|
logoSvgBase64: Buffer.from(logoSvg).toString("base64"),
|
|
avatarPngs,
|
|
socialImages,
|
|
signatureHtml: signature,
|
|
letterheadHtml: letterhead,
|
|
guidelinesPdfBase64: guidelinesPdf.toString("base64"),
|
|
zipBase64: zipBuffer.toString("base64"),
|
|
};
|
|
|
|
return {
|
|
filename: "brand-kit-bundle.json",
|
|
contentType: "application/json",
|
|
buffer: Buffer.from(JSON.stringify(bundle)),
|
|
};
|
|
}
|