import { createWriteStream } from "node:fs"; import { writeFile } from "node:fs/promises"; import path from "node:path"; import { finished } from "node:stream/promises"; import { createZipArchive } from "@/lib/create-zip"; import { PRESET_FONTS, isPresetFont } from "@/lib/fonts"; import { generatePngsInWorkers, type WorkerFont } from "@/lib/generate-pool"; import { CUSTOM_FONT_FAMILY, formatUserId, MAX_FONT_BYTES, MAX_IMAGE_BYTES, parseGenerateConfig, sanitizeFilename, toIdDrawStyle, } from "@/lib/id-config"; import { createJobDir } from "@/lib/jobs"; import type { GenerateProgressEvent } from "@/lib/progress"; export const runtime = "nodejs"; export const maxDuration = 300; export const dynamic = "force-dynamic"; function jsonError(message: string, status = 400): Response { return Response.json({ error: message }, { status }); } function presetFonts(): WorkerFont[] { const dir = path.join(process.cwd(), "public", "fonts"); return PRESET_FONTS.flatMap((font) => [ { path: path.join(dir, font.regular), family: font.id }, { path: path.join(dir, font.bold), family: font.id }, ]); } export async function POST(req: Request): Promise { try { const form = await req.formData(); const imageFile = form.get("image"); const fontFile = form.get("font"); const configRaw = form.get("config"); if (!(imageFile instanceof File)) { return jsonError("A base image is required"); } if (imageFile.size === 0) { return jsonError("The base image is empty"); } if (imageFile.size > MAX_IMAGE_BYTES) { return jsonError("Base image must be 10MB or smaller"); } if (typeof configRaw !== "string") { return jsonError("Missing generate config"); } let config; try { config = parseGenerateConfig(JSON.parse(configRaw)); } catch (error) { const message = error instanceof Error ? error.message : "Invalid generate config"; return jsonError(message); } if (config.useCustomFont) { if (!(fontFile instanceof File) || fontFile.size === 0) { return jsonError("Custom font file is required when using a custom font"); } if (fontFile.size > MAX_FONT_BYTES) { return jsonError("Custom font must be 5MB or smaller"); } } else if (!isPresetFont(config.fontFamily)) { return jsonError("Unknown font family"); } const encoder = new TextEncoder(); const stream = new ReadableStream({ async start(controller) { const send = (event: GenerateProgressEvent) => { controller.enqueue(encoder.encode(`${JSON.stringify(event)}\n`)); }; try { const job = await createJobDir(); const imagePath = `${job.imagePath}.bin`; await writeFile(imagePath, Buffer.from(await imageFile.arrayBuffer())); const fonts = presetFonts(); if (config.useCustomFont && fontFile instanceof File) { const ext = fontFile.name.toLowerCase().endsWith(".otf") ? ".otf" : ".ttf"; const fontPath = path.join(job.dir, `custom${ext}`); await writeFile(fontPath, Buffer.from(await fontFile.arrayBuffer())); fonts.push({ path: fontPath, family: CUSTOM_FONT_FAMILY }); } const style = toIdDrawStyle(config); const usedNames = new Set(); const files: { name: string; path: string; text: string }[] = []; for (let i = 0; i < config.count; i += 1) { const id = formatUserId( config.prefix, config.start + i, config.pad, config.suffix, ); let name = `${sanitizeFilename(id)}.png`; if (usedNames.has(name)) { name = `${sanitizeFilename(id)}-${i}.png`; } usedNames.add(name); files.push({ name, path: path.join(job.pngDir, name), text: id, }); } send({ phase: "generate", current: 0, total: files.length }); await generatePngsInWorkers({ imagePath, fonts, style, tasks: files.map((file) => ({ text: file.text, outPath: file.path, })), onProgress: (current, total) => { send({ phase: "generate", current, total }); }, }); send({ phase: "zip", current: 0, total: files.length }); const output = createWriteStream(job.zipPath); const archive = createZipArchive(); archive.on("error", (error: Error) => { output.destroy(error); }); archive.on("progress", (progress) => { send({ phase: "zip", current: progress.entries.processed, total: Math.max(progress.entries.total, files.length), }); }); archive.pipe(output); for (const file of files) { archive.file(file.path, { name: file.name }); } await archive.finalize(); await finished(output); send({ phase: "zip", current: files.length, total: files.length }); send({ phase: "done", id: job.id }); controller.close(); } catch (error) { const message = error instanceof Error ? error.message : "Failed to generate images"; send({ phase: "error", message }); controller.close(); } }, }); return new Response(stream, { headers: { "Content-Type": "application/x-ndjson", "Cache-Control": "no-store", "X-Accel-Buffering": "no", }, }); } catch (error) { const message = error instanceof Error ? error.message : "Failed to generate images"; return jsonError(message, 500); } }