182 lines
5.8 KiB
TypeScript
182 lines
5.8 KiB
TypeScript
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<Response> {
|
|
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<Uint8Array>({
|
|
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<string>();
|
|
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);
|
|
}
|
|
}
|