nexus/server/src/services/renderers/brand-renderer.ts

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