progress indicator added

This commit is contained in:
2026-09-13 15:30:23 +05:30
parent 9c7a16b57c
commit 13c24e9db3
5 changed files with 319 additions and 69 deletions
+34
View File
@@ -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<Response> {
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",
},
});
}
+69 -37
View File
@@ -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<Response> {
let tmpDir: string | null = null;
try {
const form = await req.formData();
const imageFile = form.get("image");
@@ -67,13 +68,26 @@ export async function POST(req: Request): Promise<Response> {
return jsonError("Unknown font family");
}
tmpDir = await mkdtemp(path.join(tmpdir(), "kk-card-"));
registerPresetFonts();
const encoder = new TextEncoder();
const stream = new ReadableStream<Uint8Array>({
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);
if (config.useCustomFont && fontFile instanceof File) {
const ext = fontFile.name.toLowerCase().endsWith(".otf") ? ".otf" : ".ttf";
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);
@@ -84,18 +98,9 @@ export async function POST(req: Request): Promise<Response> {
);
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 files: { name: string; path: string }[] = [];
const generate = async () => {
try {
for (let i = 0; i < config.count; i += 1) {
const id = formatUserId(
config.prefix,
@@ -112,34 +117,61 @@ export async function POST(req: Request): Promise<Response> {
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 filePath = path.join(pngDir, name);
await writeFile(filePath, png);
files.push({ name, path: filePath });
send({ phase: "generate", current: i + 1, total: config.count });
}
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();
send({ phase: "zip", current: 0, total: files.length });
return new Response(Readable.toWeb(passThrough) as ReadableStream, {
headers: {
"Content-Type": "application/zip",
"Content-Disposition": 'attachment; filename="ids.zip"',
"Cache-Control": "no-store",
},
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();
}
},
});
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);
+139 -4
View File
@@ -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<GenerateProgressEvent, { phase: "generate" | "zip" }>) => void,
): Promise<string> {
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<string | null>(null);
const [status, setStatus] = useState<string | null>(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}
</p>
) : null}
{status && !error ? (
{progress ? <ProgressPanel progress={progress} /> : null}
{status && !error && !progress ? (
<p className="rounded-lg border border-indigo-900 bg-indigo-950/50 px-3 py-2 text-sm text-indigo-100">
{status}
</p>
@@ -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`}
</button>
</aside>
</div>
@@ -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 (
<div className="rounded-lg border border-indigo-900 bg-indigo-950/50 px-3 py-3 text-sm text-indigo-100">
<div className="flex items-center justify-between gap-3">
<span>{title}</span>
<span className="font-mono text-xs text-indigo-200">
{progress.phase === "download"
? "ids.zip"
: `${progress.current} / ${progress.total}`}
</span>
</div>
<div className="mt-2 h-2 overflow-hidden rounded-full bg-zinc-800">
<div
className="h-full rounded-full bg-indigo-400 transition-[width] duration-150"
style={{ width: `${percent}%` }}
/>
</div>
</div>
);
}
function FieldGroup({
title,
children,
+33
View File
@@ -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<string, Job>();
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;
}
+16
View File
@@ -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"
);
}