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}