progress indicator added
This commit is contained in:
+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);
|
||||
|
||||
Reference in New Issue
Block a user