From 13c24e9db3e63163282fb0b13073b744c00481e7 Mon Sep 17 00:00:00 2001 From: Sewmina Date: Sun, 13 Sep 2026 15:30:23 +0530 Subject: [PATCH] progress indicator added --- app/api/download/[jobId]/route.ts | 34 +++++++ app/api/generate/route.ts | 162 ++++++++++++++++++------------ app/editor.tsx | 143 +++++++++++++++++++++++++- lib/jobs.ts | 33 ++++++ lib/progress.ts | 16 +++ 5 files changed, 319 insertions(+), 69 deletions(-) create mode 100644 app/api/download/[jobId]/route.ts create mode 100644 lib/jobs.ts create mode 100644 lib/progress.ts diff --git a/app/api/download/[jobId]/route.ts b/app/api/download/[jobId]/route.ts new file mode 100644 index 0000000..1a9ecf1 --- /dev/null +++ b/app/api/download/[jobId]/route.ts @@ -0,0 +1,34 @@ +import { createReadStream } from "node:fs"; +import { rm } from "node:fs/promises"; +import { Readable } from "node:stream"; +import { takeJob } from "@/lib/jobs"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function GET( + _req: Request, + context: { params: Promise<{ jobId: string }> }, +): Promise { + const { jobId } = await context.params; + const job = takeJob(jobId); + if (!job) { + 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, { + headers: { + "Content-Type": "application/zip", + "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 e98e895..144a244 100644 --- a/app/api/generate/route.ts +++ b/app/api/generate/route.ts @@ -1,8 +1,9 @@ import { createCanvas, loadImage, GlobalFonts } from "@napi-rs/canvas"; -import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { createWriteStream } from "node:fs"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; -import { PassThrough, Readable } from "node:stream"; +import { finished } from "node:stream/promises"; import { createZipArchive } from "@/lib/create-zip"; import { drawIdText } from "@/lib/draw-id"; import { isPresetFont } from "@/lib/fonts"; @@ -15,6 +16,8 @@ import { sanitizeFilename, toIdDrawStyle, } from "@/lib/id-config"; +import { saveJob } from "@/lib/jobs"; +import type { GenerateProgressEvent } from "@/lib/progress"; import { registerPresetFonts } from "@/lib/register-fonts"; export const runtime = "nodejs"; @@ -26,8 +29,6 @@ function jsonError(message: string, status = 400): Response { } export async function POST(req: Request): Promise { - let tmpDir: string | null = null; - try { const form = await req.formData(); const imageFile = form.get("image"); @@ -67,79 +68,110 @@ export async function POST(req: Request): Promise { return jsonError("Unknown font family"); } - tmpDir = await mkdtemp(path.join(tmpdir(), "kk-card-")); - registerPresetFonts(); + 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`)); + }; - const style = toIdDrawStyle(config); + try { + tmpDir = await mkdtemp(path.join(tmpdir(), "kk-card-")); + const pngDir = path.join(tmpDir, "png"); + await mkdir(pngDir); - 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); - } + registerPresetFonts(); + const style = toIdDrawStyle(config); - 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(); - - 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`; + 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); } - 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 }); + const image = await loadImage( + Buffer.from(await imageFile.arrayBuffer()), + ); + const canvas = createCanvas(image.width, image.height); + const ctx = canvas.getContext("2d"); + const usedNames = new Set(); + const files: { name: string; path: 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); + + 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 }); + } + + send({ phase: "zip", current: 0, total: files.length }); + + const zipPath = path.join(tmpDir, "ids.zip"); + const output = createWriteStream(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 }); + + const id = saveJob(tmpDir, zipPath); + tmpDir = null; + send({ phase: "done", 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 }); + controller.close(); } - 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, { + return new Response(stream, { headers: { - "Content-Type": "application/zip", - "Content-Disposition": 'attachment; filename="ids.zip"', + "Content-Type": "application/x-ndjson", "Cache-Control": "no-store", + "X-Accel-Buffering": "no", }, }); } 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); diff --git a/app/editor.tsx b/app/editor.tsx index da95c03..21cfdb6 100644 --- a/app/editor.tsx +++ b/app/editor.tsx @@ -29,10 +29,60 @@ import { type GenerateConfig, type TextAlign, } from "@/lib/id-config"; +import { + isProgressEvent, + type GenerateProgressEvent, +} from "@/lib/progress"; const IMAGE_ACCEPT = "image/png,image/jpeg,image/webp"; const FONT_ACCEPT = ".ttf,.otf,font/ttf,font/otf"; +async function readGenerateProgress( + response: Response, + onEvent: (event: Extract) => void, +): Promise { + if (!response.body) { + throw new Error("No progress stream from server"); + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + let jobId: string | null = null; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() ?? ""; + + for (const line of lines) { + if (!line.trim()) continue; + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch { + continue; + } + if (!isProgressEvent(parsed)) continue; + if (parsed.phase === "error") { + throw new Error(parsed.message); + } + if (parsed.phase === "done") { + jobId = parsed.id; + continue; + } + onEvent(parsed); + } + } + + if (!jobId) { + throw new Error("Generation finished without a download"); + } + return jobId; +} + function canvasPoint( canvas: HTMLCanvasElement, clientX: number, @@ -60,6 +110,11 @@ export function Editor() { const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const [status, setStatus] = useState(null); + const [progress, setProgress] = useState<{ + phase: "generate" | "zip" | "download"; + current: number; + total: number; + } | null>(null); const [cursor, setCursor] = useState<"grab" | "grabbing" | "default">( "default", ); @@ -298,7 +353,8 @@ export function Editor() { setBusy(true); setError(null); - setStatus(`Generating ${config.count} images on the server…`); + setStatus(null); + setProgress({ phase: "generate", current: 0, total: config.count }); try { const form = new FormData(); @@ -324,7 +380,23 @@ export function Editor() { throw new Error(message); } - const blob = await response.blob(); + const jobId = await readGenerateProgress(response, (event) => { + if (event.phase === "generate" || event.phase === "zip") { + setProgress({ + phase: event.phase, + current: event.current, + total: event.total, + }); + } + }); + + 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; @@ -333,8 +405,10 @@ export function Editor() { link.click(); link.remove(); URL.revokeObjectURL(url); + setProgress(null); setStatus(`Downloaded ids.zip with ${config.count} PNGs`); } catch (err) { + setProgress(null); setStatus(null); setError(err instanceof Error ? err.message : "Generation failed"); } finally { @@ -686,7 +760,8 @@ export function Editor() { {error}

) : null} - {status && !error ? ( + {progress ? : null} + {status && !error && !progress ? (

{status}

@@ -698,7 +773,9 @@ export function Editor() { disabled={busy || !imageFile} className="rounded-xl bg-indigo-500 px-4 py-3 text-sm font-semibold text-white transition hover:bg-indigo-400 disabled:cursor-not-allowed disabled:bg-zinc-700 disabled:text-zinc-400" > - {busy ? "Generating…" : `Generate ${config.count} images`} + {busy + ? progressLabel(progress, config.count) + : `Generate ${config.count} images`} @@ -706,6 +783,64 @@ export function Editor() { ); } +function progressLabel( + progress: { + phase: "generate" | "zip" | "download"; + current: number; + total: number; + } | null, + fallbackTotal: number, +): string { + if (!progress) return `Generating 0 / ${fallbackTotal}`; + if (progress.phase === "zip") { + return `Zipping ${progress.current} / ${progress.total}`; + } + if (progress.phase === "download") { + return "Downloading zip…"; + } + return `Generating ${progress.current} / ${progress.total}`; +} + +function ProgressPanel({ + progress, +}: { + progress: { + phase: "generate" | "zip" | "download"; + current: number; + total: number; + }; +}) { + const percent = + progress.total > 0 + ? Math.min(100, Math.round((progress.current / progress.total) * 100)) + : 0; + const title = + progress.phase === "zip" + ? "Zipping" + : progress.phase === "download" + ? "Downloading" + : "Generating"; + + return ( +
+
+ {title} + + {progress.phase === "download" + ? "ids.zip" + : `${progress.current} / ${progress.total}`} + +
+
+
+
+
+ ); +} + function FieldGroup({ title, children, diff --git a/lib/jobs.ts b/lib/jobs.ts new file mode 100644 index 0000000..e12484d --- /dev/null +++ b/lib/jobs.ts @@ -0,0 +1,33 @@ +import { rm } from "node:fs/promises"; + +type Job = { + dir: string; + zipPath: string; + createdAt: number; +}; + +const JOB_TTL_MS = 15 * 60 * 1000; +const jobs = new Map(); + +function sweepExpiredJobs(): void { + const cutoff = Date.now() - JOB_TTL_MS; + for (const [id, job] of jobs) { + if (job.createdAt > cutoff) continue; + jobs.delete(id); + void rm(job.dir, { recursive: true, force: true }); + } +} + +export function saveJob(dir: string, zipPath: string): string { + sweepExpiredJobs(); + const id = crypto.randomUUID(); + jobs.set(id, { dir, zipPath, createdAt: Date.now() }); + return id; +} + +export function takeJob(id: string): Job | null { + const job = jobs.get(id); + if (!job) return null; + jobs.delete(id); + return job; +} diff --git a/lib/progress.ts b/lib/progress.ts new file mode 100644 index 0000000..eccad1f --- /dev/null +++ b/lib/progress.ts @@ -0,0 +1,16 @@ +export type GenerateProgressEvent = + | { phase: "generate"; current: number; total: number } + | { phase: "zip"; current: number; total: number } + | { phase: "done"; id: string } + | { phase: "error"; message: string }; + +export function isProgressEvent(value: unknown): value is GenerateProgressEvent { + if (!value || typeof value !== "object") return false; + const phase = (value as { phase?: unknown }).phase; + return ( + phase === "generate" || + phase === "zip" || + phase === "done" || + phase === "error" + ); +}