progress indicator added
This commit is contained in:
@@ -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",
|
||||
},
|
||||
});
|
||||
}
|
||||
+97
-65
@@ -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,79 +68,110 @@ 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`));
|
||||
};
|
||||
|
||||
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<string>();
|
||||
|
||||
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<string>();
|
||||
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);
|
||||
|
||||
+139
-4
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user