promises, link
This commit is contained in:
@@ -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<Response> {
|
||||
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",
|
||||
},
|
||||
|
||||
+41
-39
@@ -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<Response> {
|
||||
try {
|
||||
const form = await req.formData();
|
||||
@@ -71,35 +76,28 @@ export async function POST(req: Request): Promise<Response> {
|
||||
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);
|
||||
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<string>();
|
||||
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<Response> {
|
||||
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<Response> {
|
||||
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 });
|
||||
|
||||
+18
-31
@@ -111,10 +111,11 @@ export function Editor() {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [status, setStatus] = useState<string | null>(null);
|
||||
const [progress, setProgress] = useState<{
|
||||
phase: "generate" | "zip" | "download";
|
||||
phase: "generate" | "zip";
|
||||
current: number;
|
||||
total: number;
|
||||
} | null>(null);
|
||||
const [downloadHref, setDownloadHref] = useState<string | null>(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}
|
||||
</p>
|
||||
) : null}
|
||||
{downloadHref && !busy ? (
|
||||
<a
|
||||
href={downloadHref}
|
||||
download="ids.zip"
|
||||
className="rounded-xl bg-emerald-500 px-4 py-3 text-center text-sm font-semibold text-white transition hover:bg-emerald-400"
|
||||
>
|
||||
Download ids.zip
|
||||
</a>
|
||||
) : null}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
@@ -785,7 +782,7 @@ export function Editor() {
|
||||
|
||||
function progressLabel(
|
||||
progress: {
|
||||
phase: "generate" | "zip" | "download";
|
||||
phase: "generate" | "zip";
|
||||
current: number;
|
||||
total: number;
|
||||
} | null,
|
||||
@@ -795,9 +792,6 @@ function progressLabel(
|
||||
if (progress.phase === "zip") {
|
||||
return `Zipping ${progress.current} / ${progress.total}`;
|
||||
}
|
||||
if (progress.phase === "download") {
|
||||
return "Downloading zip…";
|
||||
}
|
||||
return `Generating ${progress.current} / ${progress.total}`;
|
||||
}
|
||||
|
||||
@@ -805,7 +799,7 @@ function ProgressPanel({
|
||||
progress,
|
||||
}: {
|
||||
progress: {
|
||||
phase: "generate" | "zip" | "download";
|
||||
phase: "generate" | "zip";
|
||||
current: number;
|
||||
total: number;
|
||||
};
|
||||
@@ -814,21 +808,14 @@ function ProgressPanel({
|
||||
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";
|
||||
const title = progress.phase === "zip" ? "Zipping" : "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}`}
|
||||
{progress.current} / {progress.total}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 h-2 overflow-hidden rounded-full bg-zinc-800">
|
||||
|
||||
Reference in New Issue
Block a user