diff --git a/app/api/download/[jobId]/route.ts b/app/api/download/[jobId]/route.ts
index 1a9ecf1..ddb3416 100644
--- a/app/api/download/[jobId]/route.ts
+++ b/app/api/download/[jobId]/route.ts
@@ -1,7 +1,6 @@
import { createReadStream } from "node:fs";
-import { rm } from "node:fs/promises";
import { Readable } from "node:stream";
-import { takeJob } from "@/lib/jobs";
+import { getJobZip } from "@/lib/jobs";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
@@ -11,22 +10,18 @@ export async function GET(
context: { params: Promise<{ jobId: string }> },
): Promise {
const { jobId } = await context.params;
- const job = takeJob(jobId);
+ const job = await getJobZip(jobId);
if (!job) {
- return Response.json({ error: "Download expired or not found" }, { status: 404 });
+ return Response.json(
+ { error: "Download expired or not found" },
+ { status: 404 },
+ );
}
- const file = createReadStream(job.zipPath);
- file.on("close", () => {
- void rm(job.dir, { recursive: true, force: true });
- });
- file.on("error", () => {
- void rm(job.dir, { recursive: true, force: true });
- });
-
- return new Response(Readable.toWeb(file) as ReadableStream, {
+ return new Response(Readable.toWeb(createReadStream(job.zipPath)) as ReadableStream, {
headers: {
"Content-Type": "application/zip",
+ "Content-Length": String(job.size),
"Content-Disposition": 'attachment; filename="ids.zip"',
"Cache-Control": "no-store",
},
diff --git a/app/api/generate/route.ts b/app/api/generate/route.ts
index 144a244..ef6fbcf 100644
--- a/app/api/generate/route.ts
+++ b/app/api/generate/route.ts
@@ -1,12 +1,10 @@
-import { createCanvas, loadImage, GlobalFonts } from "@napi-rs/canvas";
import { createWriteStream } from "node:fs";
-import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
-import { tmpdir } from "node:os";
+import { writeFile } from "node:fs/promises";
import path from "node:path";
import { finished } from "node:stream/promises";
import { createZipArchive } from "@/lib/create-zip";
-import { drawIdText } from "@/lib/draw-id";
-import { isPresetFont } from "@/lib/fonts";
+import { PRESET_FONTS, isPresetFont } from "@/lib/fonts";
+import { generatePngsInWorkers, type WorkerFont } from "@/lib/generate-pool";
import {
CUSTOM_FONT_FAMILY,
formatUserId,
@@ -16,9 +14,8 @@ import {
sanitizeFilename,
toIdDrawStyle,
} from "@/lib/id-config";
-import { saveJob } from "@/lib/jobs";
+import { createJobDir } from "@/lib/jobs";
import type { GenerateProgressEvent } from "@/lib/progress";
-import { registerPresetFonts } from "@/lib/register-fonts";
export const runtime = "nodejs";
export const maxDuration = 300;
@@ -28,6 +25,14 @@ 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();
@@ -71,35 +76,28 @@ export async function POST(req: Request): Promise {
const encoder = new TextEncoder();
const stream = new ReadableStream({
async start(controller) {
- let tmpDir: string | null = null;
const send = (event: GenerateProgressEvent) => {
controller.enqueue(encoder.encode(`${JSON.stringify(event)}\n`));
};
try {
- tmpDir = await mkdtemp(path.join(tmpdir(), "kk-card-"));
- const pngDir = path.join(tmpDir, "png");
- await mkdir(pngDir);
-
- registerPresetFonts();
- const style = toIdDrawStyle(config);
+ 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(tmpDir, `custom${ext}`);
+ const fontPath = path.join(job.dir, `custom${ext}`);
await writeFile(fontPath, Buffer.from(await fontFile.arrayBuffer()));
- GlobalFonts.registerFromPath(fontPath, CUSTOM_FONT_FAMILY);
+ fonts.push({ path: fontPath, family: 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 style = toIdDrawStyle(config);
const usedNames = new Set();
- const files: { name: string; path: string }[] = [];
+ const files: { name: string; path: string; text: string }[] = [];
for (let i = 0; i < config.count; i += 1) {
const id = formatUserId(
@@ -113,20 +111,30 @@ export async function POST(req: Request): Promise {
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");
- const filePath = path.join(pngDir, name);
- await writeFile(filePath, png);
- files.push({ name, path: filePath });
- send({ phase: "generate", current: i + 1, total: config.count });
+ 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 zipPath = path.join(tmpDir, "ids.zip");
- const output = createWriteStream(zipPath);
+ const output = createWriteStream(job.zipPath);
const archive = createZipArchive();
archive.on("error", (error: Error) => {
output.destroy(error);
@@ -147,15 +155,9 @@ export async function POST(req: Request): Promise {
await archive.finalize();
await finished(output);
send({ phase: "zip", current: files.length, total: files.length });
-
- const id = saveJob(tmpDir, zipPath);
- tmpDir = null;
- send({ phase: "done", id });
+ send({ phase: "done", id: job.id });
controller.close();
} catch (error) {
- if (tmpDir) {
- await rm(tmpDir, { recursive: true, force: true }).catch(() => {});
- }
const message =
error instanceof Error ? error.message : "Failed to generate images";
send({ phase: "error", message });
diff --git a/app/editor.tsx b/app/editor.tsx
index 21cfdb6..deee143 100644
--- a/app/editor.tsx
+++ b/app/editor.tsx
@@ -111,10 +111,11 @@ export function Editor() {
const [error, setError] = useState(null);
const [status, setStatus] = useState(null);
const [progress, setProgress] = useState<{
- phase: "generate" | "zip" | "download";
+ phase: "generate" | "zip";
current: number;
total: number;
} | null>(null);
+ const [downloadHref, setDownloadHref] = useState(null);
const [cursor, setCursor] = useState<"grab" | "grabbing" | "default">(
"default",
);
@@ -354,6 +355,7 @@ export function Editor() {
setBusy(true);
setError(null);
setStatus(null);
+ setDownloadHref(null);
setProgress({ phase: "generate", current: 0, total: config.count });
try {
@@ -390,23 +392,9 @@ export function Editor() {
}
});
- setProgress({ phase: "download", current: 1, total: 1 });
- const zipResponse = await fetch(`/api/download/${jobId}`);
- if (!zipResponse.ok) {
- throw new Error("Could not download the zip file");
- }
-
- const blob = await zipResponse.blob();
- const url = URL.createObjectURL(blob);
- const link = document.createElement("a");
- link.href = url;
- link.download = "ids.zip";
- document.body.appendChild(link);
- link.click();
- link.remove();
- URL.revokeObjectURL(url);
setProgress(null);
- setStatus(`Downloaded ids.zip with ${config.count} PNGs`);
+ setDownloadHref(`/api/download/${jobId}`);
+ setStatus(`${config.count} images are ready. Download the zip below.`);
} catch (err) {
setProgress(null);
setStatus(null);
@@ -766,6 +754,15 @@ export function Editor() {
{status}
) : null}
+ {downloadHref && !busy ? (
+
+ Download ids.zip
+
+ ) : null}