promises, link
This commit is contained in:
@@ -1,7 +1,6 @@
|
|||||||
import { createReadStream } from "node:fs";
|
import { createReadStream } from "node:fs";
|
||||||
import { rm } from "node:fs/promises";
|
|
||||||
import { Readable } from "node:stream";
|
import { Readable } from "node:stream";
|
||||||
import { takeJob } from "@/lib/jobs";
|
import { getJobZip } from "@/lib/jobs";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
@@ -11,22 +10,18 @@ export async function GET(
|
|||||||
context: { params: Promise<{ jobId: string }> },
|
context: { params: Promise<{ jobId: string }> },
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
const { jobId } = await context.params;
|
const { jobId } = await context.params;
|
||||||
const job = takeJob(jobId);
|
const job = await getJobZip(jobId);
|
||||||
if (!job) {
|
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);
|
return new Response(Readable.toWeb(createReadStream(job.zipPath)) as ReadableStream, {
|
||||||
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: {
|
headers: {
|
||||||
"Content-Type": "application/zip",
|
"Content-Type": "application/zip",
|
||||||
|
"Content-Length": String(job.size),
|
||||||
"Content-Disposition": 'attachment; filename="ids.zip"',
|
"Content-Disposition": 'attachment; filename="ids.zip"',
|
||||||
"Cache-Control": "no-store",
|
"Cache-Control": "no-store",
|
||||||
},
|
},
|
||||||
|
|||||||
+41
-39
@@ -1,12 +1,10 @@
|
|||||||
import { createCanvas, loadImage, GlobalFonts } from "@napi-rs/canvas";
|
|
||||||
import { createWriteStream } from "node:fs";
|
import { createWriteStream } from "node:fs";
|
||||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
import { writeFile } from "node:fs/promises";
|
||||||
import { tmpdir } from "node:os";
|
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { finished } from "node:stream/promises";
|
import { finished } from "node:stream/promises";
|
||||||
import { createZipArchive } from "@/lib/create-zip";
|
import { createZipArchive } from "@/lib/create-zip";
|
||||||
import { drawIdText } from "@/lib/draw-id";
|
import { PRESET_FONTS, isPresetFont } from "@/lib/fonts";
|
||||||
import { isPresetFont } from "@/lib/fonts";
|
import { generatePngsInWorkers, type WorkerFont } from "@/lib/generate-pool";
|
||||||
import {
|
import {
|
||||||
CUSTOM_FONT_FAMILY,
|
CUSTOM_FONT_FAMILY,
|
||||||
formatUserId,
|
formatUserId,
|
||||||
@@ -16,9 +14,8 @@ import {
|
|||||||
sanitizeFilename,
|
sanitizeFilename,
|
||||||
toIdDrawStyle,
|
toIdDrawStyle,
|
||||||
} from "@/lib/id-config";
|
} from "@/lib/id-config";
|
||||||
import { saveJob } from "@/lib/jobs";
|
import { createJobDir } from "@/lib/jobs";
|
||||||
import type { GenerateProgressEvent } from "@/lib/progress";
|
import type { GenerateProgressEvent } from "@/lib/progress";
|
||||||
import { registerPresetFonts } from "@/lib/register-fonts";
|
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
export const maxDuration = 300;
|
export const maxDuration = 300;
|
||||||
@@ -28,6 +25,14 @@ function jsonError(message: string, status = 400): Response {
|
|||||||
return Response.json({ error: message }, { status });
|
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> {
|
export async function POST(req: Request): Promise<Response> {
|
||||||
try {
|
try {
|
||||||
const form = await req.formData();
|
const form = await req.formData();
|
||||||
@@ -71,35 +76,28 @@ export async function POST(req: Request): Promise<Response> {
|
|||||||
const encoder = new TextEncoder();
|
const encoder = new TextEncoder();
|
||||||
const stream = new ReadableStream<Uint8Array>({
|
const stream = new ReadableStream<Uint8Array>({
|
||||||
async start(controller) {
|
async start(controller) {
|
||||||
let tmpDir: string | null = null;
|
|
||||||
const send = (event: GenerateProgressEvent) => {
|
const send = (event: GenerateProgressEvent) => {
|
||||||
controller.enqueue(encoder.encode(`${JSON.stringify(event)}\n`));
|
controller.enqueue(encoder.encode(`${JSON.stringify(event)}\n`));
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
tmpDir = await mkdtemp(path.join(tmpdir(), "kk-card-"));
|
const job = await createJobDir();
|
||||||
const pngDir = path.join(tmpDir, "png");
|
const imagePath = `${job.imagePath}.bin`;
|
||||||
await mkdir(pngDir);
|
await writeFile(imagePath, Buffer.from(await imageFile.arrayBuffer()));
|
||||||
|
|
||||||
registerPresetFonts();
|
|
||||||
const style = toIdDrawStyle(config);
|
|
||||||
|
|
||||||
|
const fonts = presetFonts();
|
||||||
if (config.useCustomFont && fontFile instanceof File) {
|
if (config.useCustomFont && fontFile instanceof File) {
|
||||||
const ext = fontFile.name.toLowerCase().endsWith(".otf")
|
const ext = fontFile.name.toLowerCase().endsWith(".otf")
|
||||||
? ".otf"
|
? ".otf"
|
||||||
: ".ttf";
|
: ".ttf";
|
||||||
const fontPath = path.join(tmpDir, `custom${ext}`);
|
const fontPath = path.join(job.dir, `custom${ext}`);
|
||||||
await writeFile(fontPath, Buffer.from(await fontFile.arrayBuffer()));
|
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(
|
const style = toIdDrawStyle(config);
|
||||||
Buffer.from(await imageFile.arrayBuffer()),
|
|
||||||
);
|
|
||||||
const canvas = createCanvas(image.width, image.height);
|
|
||||||
const ctx = canvas.getContext("2d");
|
|
||||||
const usedNames = new Set<string>();
|
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) {
|
for (let i = 0; i < config.count; i += 1) {
|
||||||
const id = formatUserId(
|
const id = formatUserId(
|
||||||
@@ -113,20 +111,30 @@ export async function POST(req: Request): Promise<Response> {
|
|||||||
name = `${sanitizeFilename(id)}-${i}.png`;
|
name = `${sanitizeFilename(id)}-${i}.png`;
|
||||||
}
|
}
|
||||||
usedNames.add(name);
|
usedNames.add(name);
|
||||||
|
files.push({
|
||||||
ctx.drawImage(image, 0, 0);
|
name,
|
||||||
drawIdText(ctx, id, image.width, image.height, style);
|
path: path.join(job.pngDir, name),
|
||||||
const png = await canvas.encode("png");
|
text: id,
|
||||||
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: "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 });
|
send({ phase: "zip", current: 0, total: files.length });
|
||||||
|
|
||||||
const zipPath = path.join(tmpDir, "ids.zip");
|
const output = createWriteStream(job.zipPath);
|
||||||
const output = createWriteStream(zipPath);
|
|
||||||
const archive = createZipArchive();
|
const archive = createZipArchive();
|
||||||
archive.on("error", (error: Error) => {
|
archive.on("error", (error: Error) => {
|
||||||
output.destroy(error);
|
output.destroy(error);
|
||||||
@@ -147,15 +155,9 @@ export async function POST(req: Request): Promise<Response> {
|
|||||||
await archive.finalize();
|
await archive.finalize();
|
||||||
await finished(output);
|
await finished(output);
|
||||||
send({ phase: "zip", current: files.length, total: files.length });
|
send({ phase: "zip", current: files.length, total: files.length });
|
||||||
|
send({ phase: "done", id: job.id });
|
||||||
const id = saveJob(tmpDir, zipPath);
|
|
||||||
tmpDir = null;
|
|
||||||
send({ phase: "done", id });
|
|
||||||
controller.close();
|
controller.close();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (tmpDir) {
|
|
||||||
await rm(tmpDir, { recursive: true, force: true }).catch(() => {});
|
|
||||||
}
|
|
||||||
const message =
|
const message =
|
||||||
error instanceof Error ? error.message : "Failed to generate images";
|
error instanceof Error ? error.message : "Failed to generate images";
|
||||||
send({ phase: "error", message });
|
send({ phase: "error", message });
|
||||||
|
|||||||
+18
-31
@@ -111,10 +111,11 @@ export function Editor() {
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [status, setStatus] = useState<string | null>(null);
|
const [status, setStatus] = useState<string | null>(null);
|
||||||
const [progress, setProgress] = useState<{
|
const [progress, setProgress] = useState<{
|
||||||
phase: "generate" | "zip" | "download";
|
phase: "generate" | "zip";
|
||||||
current: number;
|
current: number;
|
||||||
total: number;
|
total: number;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
const [downloadHref, setDownloadHref] = useState<string | null>(null);
|
||||||
const [cursor, setCursor] = useState<"grab" | "grabbing" | "default">(
|
const [cursor, setCursor] = useState<"grab" | "grabbing" | "default">(
|
||||||
"default",
|
"default",
|
||||||
);
|
);
|
||||||
@@ -354,6 +355,7 @@ export function Editor() {
|
|||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
setStatus(null);
|
setStatus(null);
|
||||||
|
setDownloadHref(null);
|
||||||
setProgress({ phase: "generate", current: 0, total: config.count });
|
setProgress({ phase: "generate", current: 0, total: config.count });
|
||||||
|
|
||||||
try {
|
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);
|
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) {
|
} catch (err) {
|
||||||
setProgress(null);
|
setProgress(null);
|
||||||
setStatus(null);
|
setStatus(null);
|
||||||
@@ -766,6 +754,15 @@ export function Editor() {
|
|||||||
{status}
|
{status}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : 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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -785,7 +782,7 @@ export function Editor() {
|
|||||||
|
|
||||||
function progressLabel(
|
function progressLabel(
|
||||||
progress: {
|
progress: {
|
||||||
phase: "generate" | "zip" | "download";
|
phase: "generate" | "zip";
|
||||||
current: number;
|
current: number;
|
||||||
total: number;
|
total: number;
|
||||||
} | null,
|
} | null,
|
||||||
@@ -795,9 +792,6 @@ function progressLabel(
|
|||||||
if (progress.phase === "zip") {
|
if (progress.phase === "zip") {
|
||||||
return `Zipping ${progress.current} / ${progress.total}`;
|
return `Zipping ${progress.current} / ${progress.total}`;
|
||||||
}
|
}
|
||||||
if (progress.phase === "download") {
|
|
||||||
return "Downloading zip…";
|
|
||||||
}
|
|
||||||
return `Generating ${progress.current} / ${progress.total}`;
|
return `Generating ${progress.current} / ${progress.total}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -805,7 +799,7 @@ function ProgressPanel({
|
|||||||
progress,
|
progress,
|
||||||
}: {
|
}: {
|
||||||
progress: {
|
progress: {
|
||||||
phase: "generate" | "zip" | "download";
|
phase: "generate" | "zip";
|
||||||
current: number;
|
current: number;
|
||||||
total: number;
|
total: number;
|
||||||
};
|
};
|
||||||
@@ -814,21 +808,14 @@ function ProgressPanel({
|
|||||||
progress.total > 0
|
progress.total > 0
|
||||||
? Math.min(100, Math.round((progress.current / progress.total) * 100))
|
? Math.min(100, Math.round((progress.current / progress.total) * 100))
|
||||||
: 0;
|
: 0;
|
||||||
const title =
|
const title = progress.phase === "zip" ? "Zipping" : "Generating";
|
||||||
progress.phase === "zip"
|
|
||||||
? "Zipping"
|
|
||||||
: progress.phase === "download"
|
|
||||||
? "Downloading"
|
|
||||||
: "Generating";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border border-indigo-900 bg-indigo-950/50 px-3 py-3 text-sm text-indigo-100">
|
<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">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<span>{title}</span>
|
<span>{title}</span>
|
||||||
<span className="font-mono text-xs text-indigo-200">
|
<span className="font-mono text-xs text-indigo-200">
|
||||||
{progress.phase === "download"
|
{progress.current} / {progress.total}
|
||||||
? "ids.zip"
|
|
||||||
: `${progress.current} / ${progress.total}`}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 h-2 overflow-hidden rounded-full bg-zinc-800">
|
<div className="mt-2 h-2 overflow-hidden rounded-full bg-zinc-800">
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { createRequire } from "node:module";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import type { IdDrawStyle } from "./id-config";
|
||||||
|
|
||||||
|
export type WorkerFont = {
|
||||||
|
path: string;
|
||||||
|
family: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GenerateTask = {
|
||||||
|
text: string;
|
||||||
|
outPath: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const nodeRequire = createRequire(path.join(process.cwd(), "package.json"));
|
||||||
|
|
||||||
|
function spawnWorker(workerPath: string, workerData: unknown) {
|
||||||
|
const threads = nodeRequire("node:worker_threads") as typeof import("node:worker_threads");
|
||||||
|
return new threads.Worker(workerPath, { workerData });
|
||||||
|
}
|
||||||
|
|
||||||
|
function workerCount(taskCount: number): number {
|
||||||
|
const cpus =
|
||||||
|
typeof os.availableParallelism === "function"
|
||||||
|
? os.availableParallelism()
|
||||||
|
: os.cpus().length;
|
||||||
|
return Math.max(1, Math.min(cpus, 8, taskCount));
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitTasks<T>(items: T[], parts: number): T[][] {
|
||||||
|
const chunks: T[][] = Array.from({ length: parts }, () => []);
|
||||||
|
items.forEach((item, index) => {
|
||||||
|
chunks[index % parts]?.push(item);
|
||||||
|
});
|
||||||
|
return chunks.filter((chunk) => chunk.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generatePngsInWorkers(options: {
|
||||||
|
imagePath: string;
|
||||||
|
fonts: WorkerFont[];
|
||||||
|
style: IdDrawStyle;
|
||||||
|
tasks: GenerateTask[];
|
||||||
|
onProgress: (completed: number, total: number) => void;
|
||||||
|
}): Promise<void> {
|
||||||
|
const { imagePath, fonts, style, tasks, onProgress } = options;
|
||||||
|
if (tasks.length === 0) return;
|
||||||
|
|
||||||
|
const workerPath = path.join(process.cwd(), "workers", "generate-id.cjs");
|
||||||
|
const chunks = splitTasks(tasks, workerCount(tasks.length));
|
||||||
|
let completed = 0;
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
chunks.map(
|
||||||
|
(chunk) =>
|
||||||
|
new Promise<void>((resolve, reject) => {
|
||||||
|
const worker = spawnWorker(workerPath, {
|
||||||
|
imagePath,
|
||||||
|
fonts,
|
||||||
|
style,
|
||||||
|
tasks: chunk,
|
||||||
|
});
|
||||||
|
|
||||||
|
worker.on("message", (message: { type?: string }) => {
|
||||||
|
if (message?.type !== "progress") return;
|
||||||
|
completed += 1;
|
||||||
|
onProgress(completed, tasks.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
worker.once("error", reject);
|
||||||
|
worker.once("exit", (code) => {
|
||||||
|
if (code === 0) {
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
reject(new Error(`Image worker stopped with code ${code}`));
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
+67
-22
@@ -1,33 +1,78 @@
|
|||||||
import { rm } from "node:fs/promises";
|
import { mkdir, readdir, rm, stat } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
type Job = {
|
import path from "node:path";
|
||||||
dir: string;
|
|
||||||
zipPath: string;
|
|
||||||
createdAt: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
const JOB_TTL_MS = 15 * 60 * 1000;
|
const JOB_TTL_MS = 15 * 60 * 1000;
|
||||||
const jobs = new Map<string, Job>();
|
const JOBS_ROOT = path.join(tmpdir(), "kk-card-jobs");
|
||||||
|
const JOB_ID_RE =
|
||||||
|
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||||
|
|
||||||
|
export function isJobId(id: string): boolean {
|
||||||
|
return JOB_ID_RE.test(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function jobDir(id: string): string {
|
||||||
|
return path.join(JOBS_ROOT, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function jobZipPath(id: string): string {
|
||||||
|
return path.join(jobDir(id), "ids.zip");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sweepExpiredJobs(): Promise<void> {
|
||||||
|
let names: string[] = [];
|
||||||
|
try {
|
||||||
|
names = await readdir(JOBS_ROOT);
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
function sweepExpiredJobs(): void {
|
|
||||||
const cutoff = Date.now() - JOB_TTL_MS;
|
const cutoff = Date.now() - JOB_TTL_MS;
|
||||||
for (const [id, job] of jobs) {
|
await Promise.all(
|
||||||
if (job.createdAt > cutoff) continue;
|
names.map(async (name) => {
|
||||||
jobs.delete(id);
|
const dir = path.join(JOBS_ROOT, name);
|
||||||
void rm(job.dir, { recursive: true, force: true });
|
try {
|
||||||
|
const info = await stat(dir);
|
||||||
|
if (info.mtimeMs >= cutoff) return;
|
||||||
|
await rm(dir, { recursive: true, force: true });
|
||||||
|
} catch {
|
||||||
|
// ignore missing or locked job folders
|
||||||
}
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function saveJob(dir: string, zipPath: string): string {
|
export async function createJobDir(): Promise<{
|
||||||
sweepExpiredJobs();
|
id: string;
|
||||||
|
dir: string;
|
||||||
|
pngDir: string;
|
||||||
|
imagePath: string;
|
||||||
|
zipPath: string;
|
||||||
|
}> {
|
||||||
|
await sweepExpiredJobs();
|
||||||
const id = crypto.randomUUID();
|
const id = crypto.randomUUID();
|
||||||
jobs.set(id, { dir, zipPath, createdAt: Date.now() });
|
const dir = jobDir(id);
|
||||||
return id;
|
const pngDir = path.join(dir, "png");
|
||||||
|
await mkdir(pngDir, { recursive: true });
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
dir,
|
||||||
|
pngDir,
|
||||||
|
imagePath: path.join(dir, "base"),
|
||||||
|
zipPath: jobZipPath(id),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function takeJob(id: string): Job | null {
|
export async function getJobZip(
|
||||||
const job = jobs.get(id);
|
id: string,
|
||||||
if (!job) return null;
|
): Promise<{ zipPath: string; size: number } | null> {
|
||||||
jobs.delete(id);
|
if (!isJobId(id)) return null;
|
||||||
return job;
|
const zipPath = jobZipPath(id);
|
||||||
|
try {
|
||||||
|
const info = await stat(zipPath);
|
||||||
|
if (!info.isFile() || info.size === 0) return null;
|
||||||
|
return { zipPath, size: info.size };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
const { createCanvas, loadImage, GlobalFonts } = require("@napi-rs/canvas");
|
||||||
|
const { writeFile } = require("node:fs/promises");
|
||||||
|
const { parentPort, workerData } = require("node:worker_threads");
|
||||||
|
|
||||||
|
function clamp(value, min, max) {
|
||||||
|
return Math.min(max, Math.max(min, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function hexToRgba(hex, opacity) {
|
||||||
|
let value = hex.replace("#", "");
|
||||||
|
if (value.length === 3) {
|
||||||
|
value = value
|
||||||
|
.split("")
|
||||||
|
.map((char) => char + char)
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
let alpha = clamp(opacity, 0, 1);
|
||||||
|
if (value.length === 8) {
|
||||||
|
alpha *= parseInt(value.slice(6, 8), 16) / 255;
|
||||||
|
value = value.slice(0, 6);
|
||||||
|
}
|
||||||
|
|
||||||
|
const r = parseInt(value.slice(0, 2), 16);
|
||||||
|
const g = parseInt(value.slice(2, 4), 16);
|
||||||
|
const b = parseInt(value.slice(4, 6), 16);
|
||||||
|
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCanvasFont(style) {
|
||||||
|
const italic = style.italic ? "italic " : "";
|
||||||
|
const weight = style.bold ? "700 " : "400 ";
|
||||||
|
return `${italic}${weight}${style.fontSize}px "${style.fontFamily}"`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawIdText(ctx, text, imageWidth, imageHeight, style) {
|
||||||
|
const x = (style.xPercent / 100) * imageWidth;
|
||||||
|
const y = (style.yPercent / 100) * imageHeight;
|
||||||
|
ctx.font = buildCanvasFont(style);
|
||||||
|
ctx.textAlign = style.align;
|
||||||
|
ctx.textBaseline = "alphabetic";
|
||||||
|
const metrics = ctx.measureText(text);
|
||||||
|
const ascent = metrics.actualBoundingBoxAscent ?? style.fontSize * 0.8;
|
||||||
|
const descent = metrics.actualBoundingBoxDescent ?? style.fontSize * 0.2;
|
||||||
|
const baselineY = y + (ascent - descent) / 2;
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
ctx.fillStyle = style.color;
|
||||||
|
if (style.shadowEnabled) {
|
||||||
|
ctx.shadowColor = hexToRgba(style.shadowColor, style.shadowOpacity);
|
||||||
|
ctx.shadowBlur = style.shadowBlur;
|
||||||
|
ctx.shadowOffsetX = style.shadowOffsetX;
|
||||||
|
ctx.shadowOffsetY = style.shadowOffsetY;
|
||||||
|
} else {
|
||||||
|
ctx.shadowColor = "transparent";
|
||||||
|
ctx.shadowBlur = 0;
|
||||||
|
ctx.shadowOffsetX = 0;
|
||||||
|
ctx.shadowOffsetY = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (style.outlineEnabled && style.outlineWidth > 0) {
|
||||||
|
ctx.strokeStyle = hexToRgba(style.outlineColor, style.outlineOpacity);
|
||||||
|
ctx.lineWidth = style.outlineWidth;
|
||||||
|
ctx.lineJoin = "round";
|
||||||
|
ctx.miterLimit = 2;
|
||||||
|
ctx.strokeText(text, x, baselineY);
|
||||||
|
ctx.shadowColor = "transparent";
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.fillText(text, x, baselineY);
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
const { imagePath, fonts, style, tasks } = workerData;
|
||||||
|
|
||||||
|
for (const font of fonts) {
|
||||||
|
GlobalFonts.registerFromPath(font.path, font.family);
|
||||||
|
}
|
||||||
|
|
||||||
|
const image = await loadImage(imagePath);
|
||||||
|
const canvas = createCanvas(image.width, image.height);
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
|
||||||
|
for (const task of tasks) {
|
||||||
|
ctx.drawImage(image, 0, 0);
|
||||||
|
drawIdText(ctx, task.text, image.width, image.height, style);
|
||||||
|
await writeFile(task.outPath, await canvas.encode("png"));
|
||||||
|
parentPort?.postMessage({ type: "progress" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
run().catch((error) => {
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user