Files
kk_card_gen/app/api/generate/route.ts
T
2026-09-13 14:58:27 +05:30

148 lines
4.5 KiB
TypeScript

import { createCanvas, loadImage, GlobalFonts } from "@napi-rs/canvas";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { PassThrough, Readable } from "node:stream";
import { createZipArchive } from "@/lib/create-zip";
import { drawIdText } from "@/lib/draw-id";
import { isPresetFont } from "@/lib/fonts";
import {
CUSTOM_FONT_FAMILY,
formatUserId,
MAX_FONT_BYTES,
MAX_IMAGE_BYTES,
parseGenerateConfig,
sanitizeFilename,
toIdDrawStyle,
} from "@/lib/id-config";
import { registerPresetFonts } from "@/lib/register-fonts";
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 });
}
export async function POST(req: Request): Promise<Response> {
let tmpDir: string | null = null;
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");
}
tmpDir = await mkdtemp(path.join(tmpdir(), "kk-card-"));
registerPresetFonts();
const style = toIdDrawStyle(config);
if (config.useCustomFont && fontFile instanceof File) {
const ext = fontFile.name.toLowerCase().endsWith(".otf") ? ".otf" : ".ttf";
const fontPath = path.join(tmpDir, `custom${ext}`);
await writeFile(fontPath, Buffer.from(await fontFile.arrayBuffer()));
GlobalFonts.registerFromPath(fontPath, CUSTOM_FONT_FAMILY);
}
const image = await loadImage(
Buffer.from(await imageFile.arrayBuffer()),
);
const canvas = createCanvas(image.width, image.height);
const ctx = canvas.getContext("2d");
const passThrough = new PassThrough();
const archive = createZipArchive();
archive.on("error", (error: Error) => {
passThrough.destroy(error);
});
archive.pipe(passThrough);
const usedNames = new Set<string>();
const generate = async () => {
try {
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);
ctx.drawImage(image, 0, 0);
drawIdText(ctx, id, image.width, image.height, style);
const png = await canvas.encode("png");
archive.append(Buffer.from(png), { name });
}
await archive.finalize();
} catch (error) {
archive.abort();
passThrough.destroy(
error instanceof Error ? error : new Error("Failed to generate zip"),
);
} finally {
if (tmpDir) {
await rm(tmpDir, { recursive: true, force: true }).catch(() => {});
}
}
};
void generate();
return new Response(Readable.toWeb(passThrough) as ReadableStream, {
headers: {
"Content-Type": "application/zip",
"Content-Disposition": 'attachment; filename="ids.zip"',
"Cache-Control": "no-store",
},
});
} catch (error) {
if (tmpDir) {
await rm(tmpDir, { recursive: true, force: true }).catch(() => {});
}
const message =
error instanceof Error ? error.message : "Failed to generate images";
return jsonError(message, 500);
}
}